masterytrace-cli 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.
- masterytrace/__init__.py +68 -0
- masterytrace/adapters/__init__.py +0 -0
- masterytrace/adapters/generic_adapter.py +134 -0
- masterytrace/cli/__init__.py +0 -0
- masterytrace/cli/commands/__init__.py +0 -0
- masterytrace/cli/commands/init.py +57 -0
- masterytrace/cli/commands/record.py +51 -0
- masterytrace/cli/commands/report.py +102 -0
- masterytrace/cli/commands/score.py +63 -0
- masterytrace/cli/format.py +17 -0
- masterytrace/cli/index.py +177 -0
- masterytrace/cli/json_encode.py +52 -0
- masterytrace/cli/types.py +21 -0
- masterytrace/core/__init__.py +0 -0
- masterytrace/core/config.py +62 -0
- masterytrace/core/engine.py +54 -0
- masterytrace/core/event_schema.py +131 -0
- masterytrace/core/scoring_model.py +68 -0
- masterytrace/data/__init__.py +0 -0
- masterytrace/data/sample_events.py +78 -0
- masterytrace/models/__init__.py +0 -0
- masterytrace/models/bkt.py +275 -0
- masterytrace/models/irt.py +285 -0
- masterytrace/py.typed +0 -0
- masterytrace_cli-0.1.0.dist-info/METADATA +362 -0
- masterytrace_cli-0.1.0.dist-info/RECORD +29 -0
- masterytrace_cli-0.1.0.dist-info/WHEEL +4 -0
- masterytrace_cli-0.1.0.dist-info/entry_points.txt +2 -0
- masterytrace_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bayesian Knowledge Tracing (BKT) scoring model. Faithful port of
|
|
3
|
+
src/models/bkt.ts -- same forward-recursion formulas, same coarse grid
|
|
4
|
+
search fitting routine, same default parameters and grid values. No
|
|
5
|
+
numerical library is used: BKT's per-response update is four scalar
|
|
6
|
+
arithmetic operations, exactly what the TypeScript original does with
|
|
7
|
+
plain numbers, so a numpy/scipy dependency would buy nothing here.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from ..core.event_schema import ResponseEvent
|
|
16
|
+
from ..core.scoring_model import FittedModel, MasteryLearnerEntry, MasteryReport, MasterySkillEntry
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class BktParams:
|
|
21
|
+
"""
|
|
22
|
+
pInit: prior probability the learner already knows the skill before
|
|
23
|
+
any evidence.
|
|
24
|
+
pTransit: probability of learning the skill on any given opportunity
|
|
25
|
+
(per response).
|
|
26
|
+
pSlip: probability of an incorrect response despite knowing the skill.
|
|
27
|
+
pGuess: probability of a correct response despite not knowing the
|
|
28
|
+
skill.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
p_init: float
|
|
32
|
+
p_transit: float
|
|
33
|
+
p_slip: float
|
|
34
|
+
p_guess: float
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
BKT_DEFAULT_PARAMS = BktParams(p_init=0.4, p_transit=0.3, p_slip=0.1, p_guess=0.2)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class BktConfig:
|
|
42
|
+
default_params: Optional[Dict[str, float]] = None
|
|
43
|
+
skill_params: Optional[Dict[str, Dict[str, float]]] = None
|
|
44
|
+
fit: bool = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class BktSkillResult:
|
|
49
|
+
learner_id: str
|
|
50
|
+
skill_id: str
|
|
51
|
+
posterior_history: List[float]
|
|
52
|
+
final_mastery: float
|
|
53
|
+
response_count: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class BktFittedModel(FittedModel):
|
|
58
|
+
params: Dict[str, BktParams] = field(default_factory=dict)
|
|
59
|
+
results: List[BktSkillResult] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _group_by_learner_skill(
|
|
63
|
+
events: List[ResponseEvent],
|
|
64
|
+
) -> Dict[str, Dict[str, List[ResponseEvent]]]:
|
|
65
|
+
"""
|
|
66
|
+
Groups events by learnerId, then by skillId, sorting each learner+
|
|
67
|
+
skill's events into chronological order. Nested dicts (rather than a
|
|
68
|
+
joined string key) are used deliberately: learnerId and skillId are
|
|
69
|
+
arbitrary user-supplied strings with no character restrictions, so any
|
|
70
|
+
chosen separator could in principle also appear inside an id and
|
|
71
|
+
corrupt the grouping. Nesting sidesteps that entirely -- same
|
|
72
|
+
rationale, same fix, as the TypeScript original.
|
|
73
|
+
"""
|
|
74
|
+
groups: Dict[str, Dict[str, List[ResponseEvent]]] = {}
|
|
75
|
+
for event in events:
|
|
76
|
+
by_skill = groups.setdefault(event.learner_id, {})
|
|
77
|
+
by_skill.setdefault(event.skill_id, []).append(event)
|
|
78
|
+
|
|
79
|
+
for by_skill in groups.values():
|
|
80
|
+
for skill_id, event_list in by_skill.items():
|
|
81
|
+
by_skill[skill_id] = sorted(event_list, key=lambda e: _parse_timestamp(e.timestamp))
|
|
82
|
+
return groups
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_timestamp(value: str) -> datetime:
|
|
86
|
+
candidate = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
87
|
+
return datetime.fromisoformat(candidate)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _group_by_skill(events: List[ResponseEvent]) -> Dict[str, List[ResponseEvent]]:
|
|
91
|
+
groups: Dict[str, List[ResponseEvent]] = {}
|
|
92
|
+
for event in events:
|
|
93
|
+
groups.setdefault(event.skill_id, []).append(event)
|
|
94
|
+
return groups
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class _ForwardRecursionDetail:
|
|
99
|
+
posterior_history: List[float]
|
|
100
|
+
predicted_probabilities: List[float]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _run_forward_recursion_detailed(responses: List[bool], params: BktParams) -> _ForwardRecursionDetail:
|
|
104
|
+
"""
|
|
105
|
+
Runs the BKT forward recursion for one chronologically ordered
|
|
106
|
+
sequence of responses, given fixed parameters:
|
|
107
|
+
|
|
108
|
+
P(L_0) = pInit
|
|
109
|
+
after correct: P(L_t|obs) = P(L_t)*(1-pSlip) / [P(L_t)*(1-pSlip) + (1-P(L_t))*pGuess]
|
|
110
|
+
after incorrect: P(L_t|obs) = P(L_t)*pSlip / [P(L_t)*pSlip + (1-P(L_t))*(1-pGuess)]
|
|
111
|
+
P(L_{t+1}) = P(L_t|obs) + (1 - P(L_t|obs)) * pTransit
|
|
112
|
+
"""
|
|
113
|
+
prior_l = params.p_init
|
|
114
|
+
posterior_history: List[float] = []
|
|
115
|
+
predicted_probabilities: List[float] = []
|
|
116
|
+
|
|
117
|
+
for correct in responses:
|
|
118
|
+
predicted_probabilities.append(prior_l * (1 - params.p_slip) + (1 - prior_l) * params.p_guess)
|
|
119
|
+
|
|
120
|
+
if correct:
|
|
121
|
+
numerator = prior_l * (1 - params.p_slip)
|
|
122
|
+
denominator = numerator + (1 - prior_l) * params.p_guess
|
|
123
|
+
posterior = prior_l if denominator == 0 else numerator / denominator
|
|
124
|
+
else:
|
|
125
|
+
numerator = prior_l * params.p_slip
|
|
126
|
+
denominator = numerator + (1 - prior_l) * (1 - params.p_guess)
|
|
127
|
+
posterior = prior_l if denominator == 0 else numerator / denominator
|
|
128
|
+
|
|
129
|
+
posterior_history.append(posterior)
|
|
130
|
+
prior_l = posterior + (1 - posterior) * params.p_transit
|
|
131
|
+
|
|
132
|
+
return _ForwardRecursionDetail(posterior_history=posterior_history, predicted_probabilities=predicted_probabilities)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def run_forward_recursion(responses: List[bool], params: BktParams) -> List[float]:
|
|
136
|
+
"""
|
|
137
|
+
Runs the BKT forward recursion for one chronologically ordered
|
|
138
|
+
sequence of responses and returns the posterior mastery probability
|
|
139
|
+
P(L_t | obs) after each response. Exported directly (in addition to
|
|
140
|
+
being used inside BktModel) so the recursion math can be
|
|
141
|
+
unit-tested against a hand-computed worked example.
|
|
142
|
+
"""
|
|
143
|
+
return _run_forward_recursion_detailed(responses, params).posterior_history
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
_GRID_PROBABILITY = [0.05, 0.2, 0.35, 0.5, 0.65, 0.8, 0.95]
|
|
147
|
+
# pSlip/pGuess are kept below 0.5 by construction: a "skill" parameter
|
|
148
|
+
# above that would mean the mechanism is more often wrong than right,
|
|
149
|
+
# which is not a meaningful slip/guess rate in practice.
|
|
150
|
+
_GRID_LOW_PROBABILITY = [0.02, 0.1, 0.2, 0.3, 0.4]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def fit_skill_params_by_grid_search(sequences: List[List[bool]]) -> BktParams:
|
|
154
|
+
"""
|
|
155
|
+
Coarse grid search over (pInit, pTransit, pSlip, pGuess) for one
|
|
156
|
+
skill's pooled response sequences, minimizing total squared error
|
|
157
|
+
between the model's pre-update predicted-correct probability and the
|
|
158
|
+
actual observed outcome at each step. This is intentionally a simple,
|
|
159
|
+
dependency-free fitting routine (not a full EM/Baum-Welch
|
|
160
|
+
implementation) that is good enough to noticeably improve on the
|
|
161
|
+
textbook defaults for a given dataset -- same trade-off the
|
|
162
|
+
TypeScript original documents and makes.
|
|
163
|
+
"""
|
|
164
|
+
best = BKT_DEFAULT_PARAMS
|
|
165
|
+
best_error = float("inf")
|
|
166
|
+
|
|
167
|
+
for p_init in _GRID_PROBABILITY:
|
|
168
|
+
for p_transit in _GRID_PROBABILITY:
|
|
169
|
+
for p_slip in _GRID_LOW_PROBABILITY:
|
|
170
|
+
for p_guess in _GRID_LOW_PROBABILITY:
|
|
171
|
+
params = BktParams(p_init=p_init, p_transit=p_transit, p_slip=p_slip, p_guess=p_guess)
|
|
172
|
+
error = 0.0
|
|
173
|
+
for sequence in sequences:
|
|
174
|
+
detail = _run_forward_recursion_detailed(sequence, params)
|
|
175
|
+
for i, correct in enumerate(sequence):
|
|
176
|
+
predicted = detail.predicted_probabilities[i] if i < len(detail.predicted_probabilities) else 0.0
|
|
177
|
+
actual = 1.0 if correct else 0.0
|
|
178
|
+
error += (predicted - actual) ** 2
|
|
179
|
+
if error < best_error:
|
|
180
|
+
best_error = error
|
|
181
|
+
best = params
|
|
182
|
+
|
|
183
|
+
return best
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class BktModel:
|
|
187
|
+
"""
|
|
188
|
+
Bayesian Knowledge Tracing scoring model. Implements the ScoringModel
|
|
189
|
+
protocol so it is interchangeable with IRT from the engine's point of
|
|
190
|
+
view.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
name = "bkt"
|
|
194
|
+
|
|
195
|
+
def __init__(self, config: Optional[BktConfig] = None) -> None:
|
|
196
|
+
self.config = config or BktConfig()
|
|
197
|
+
|
|
198
|
+
def fit(self, events: List[ResponseEvent]) -> BktFittedModel:
|
|
199
|
+
by_skill = _group_by_skill(events)
|
|
200
|
+
by_learner_skill = _group_by_learner_skill(events)
|
|
201
|
+
|
|
202
|
+
params: Dict[str, BktParams] = {}
|
|
203
|
+
for skill_id in by_skill:
|
|
204
|
+
override = (self.config.skill_params or {}).get(skill_id)
|
|
205
|
+
if override:
|
|
206
|
+
merged = {
|
|
207
|
+
"p_init": BKT_DEFAULT_PARAMS.p_init,
|
|
208
|
+
"p_transit": BKT_DEFAULT_PARAMS.p_transit,
|
|
209
|
+
"p_slip": BKT_DEFAULT_PARAMS.p_slip,
|
|
210
|
+
"p_guess": BKT_DEFAULT_PARAMS.p_guess,
|
|
211
|
+
}
|
|
212
|
+
merged.update(self.config.default_params or {})
|
|
213
|
+
merged.update(override)
|
|
214
|
+
params[skill_id] = BktParams(**merged)
|
|
215
|
+
elif self.config.fit:
|
|
216
|
+
sequences: List[List[bool]] = []
|
|
217
|
+
for by_skill_for_learner in by_learner_skill.values():
|
|
218
|
+
event_list = by_skill_for_learner.get(skill_id)
|
|
219
|
+
if event_list:
|
|
220
|
+
sequences.append([e.correct for e in event_list])
|
|
221
|
+
params[skill_id] = fit_skill_params_by_grid_search(sequences)
|
|
222
|
+
else:
|
|
223
|
+
merged = {
|
|
224
|
+
"p_init": BKT_DEFAULT_PARAMS.p_init,
|
|
225
|
+
"p_transit": BKT_DEFAULT_PARAMS.p_transit,
|
|
226
|
+
"p_slip": BKT_DEFAULT_PARAMS.p_slip,
|
|
227
|
+
"p_guess": BKT_DEFAULT_PARAMS.p_guess,
|
|
228
|
+
}
|
|
229
|
+
merged.update(self.config.default_params or {})
|
|
230
|
+
params[skill_id] = BktParams(**merged)
|
|
231
|
+
|
|
232
|
+
results: List[BktSkillResult] = []
|
|
233
|
+
for learner_id, by_skill_for_learner in by_learner_skill.items():
|
|
234
|
+
for skill_id, event_list in by_skill_for_learner.items():
|
|
235
|
+
skill_params = params.get(skill_id, BKT_DEFAULT_PARAMS)
|
|
236
|
+
posterior_history = run_forward_recursion([e.correct for e in event_list], skill_params)
|
|
237
|
+
results.append(
|
|
238
|
+
BktSkillResult(
|
|
239
|
+
learner_id=learner_id,
|
|
240
|
+
skill_id=skill_id,
|
|
241
|
+
posterior_history=posterior_history,
|
|
242
|
+
final_mastery=posterior_history[-1] if posterior_history else skill_params.p_init,
|
|
243
|
+
response_count=len(event_list),
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return BktFittedModel(model_name="bkt", params=params, results=results)
|
|
248
|
+
|
|
249
|
+
def score(self, fitted_model: BktFittedModel) -> MasteryReport:
|
|
250
|
+
learner_map: Dict[str, MasteryLearnerEntry] = {}
|
|
251
|
+
|
|
252
|
+
for result in fitted_model.results:
|
|
253
|
+
entry = learner_map.get(result.learner_id)
|
|
254
|
+
if entry is None:
|
|
255
|
+
entry = MasteryLearnerEntry(learner_id=result.learner_id, skills=[])
|
|
256
|
+
learner_map[result.learner_id] = entry
|
|
257
|
+
entry.skills.append(
|
|
258
|
+
MasterySkillEntry(
|
|
259
|
+
skill_id=result.skill_id,
|
|
260
|
+
metric="posterior_mastery_probability",
|
|
261
|
+
value=result.final_mastery,
|
|
262
|
+
response_count=result.response_count,
|
|
263
|
+
details={
|
|
264
|
+
"posterior_history": result.posterior_history,
|
|
265
|
+
"params": fitted_model.params.get(result.skill_id),
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return MasteryReport(
|
|
271
|
+
model="bkt",
|
|
272
|
+
generated_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
273
|
+
learners=list(learner_map.values()),
|
|
274
|
+
meta={"params": fitted_model.params},
|
|
275
|
+
)
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Item Response Theory (2-parameter logistic) scoring model. Faithful port
|
|
3
|
+
of src/models/irt.ts -- same joint maximum-likelihood estimation via batch
|
|
4
|
+
gradient ascent, same L2 regularization, same per-iteration gauge-fixing
|
|
5
|
+
(re-centering theta to mean 0 / std 1). The TypeScript original hand-rolls
|
|
6
|
+
this with a `Float64Array` and plain loops rather than calling into an
|
|
7
|
+
external optimizer (there is no scipy.optimize equivalent in play here to
|
|
8
|
+
port to); this port mirrors that with plain Python lists so the algorithm
|
|
9
|
+
stays line-for-line auditable against the original rather than being
|
|
10
|
+
rewritten in vectorized numpy, which could silently change rounding/
|
|
11
|
+
iteration order.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
from ..core.event_schema import ResponseEvent
|
|
21
|
+
from ..core.scoring_model import FittedModel, MasteryLearnerEntry, MasteryReport, MasterySkillEntry
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class IrtItemParams:
|
|
26
|
+
skill_id: str
|
|
27
|
+
a: float
|
|
28
|
+
"""Discrimination. Higher means the item separates high/low ability learners more sharply."""
|
|
29
|
+
b: float
|
|
30
|
+
"""Difficulty, on the same scale as theta. Higher means a harder skill."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class IrtLearnerResult:
|
|
35
|
+
learner_id: str
|
|
36
|
+
theta: float
|
|
37
|
+
"""Estimated ability."""
|
|
38
|
+
response_count: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class IrtConfig:
|
|
43
|
+
iterations: int = 500
|
|
44
|
+
"""Number of gradient-ascent iterations to run."""
|
|
45
|
+
learning_rate: float = 0.5
|
|
46
|
+
"""Learning rate for the gradient-ascent updates."""
|
|
47
|
+
regularization: float = 0.01
|
|
48
|
+
"""
|
|
49
|
+
L2 regularization strength pulling theta and b toward 0 and a toward 1.
|
|
50
|
+
This is what keeps the joint MLE finite for learners/items with a
|
|
51
|
+
perfect (all-correct or all-incorrect) response record, where the
|
|
52
|
+
unregularized likelihood is maximized at +/-infinity.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class IrtResponseTriple:
|
|
58
|
+
learner_id: str
|
|
59
|
+
skill_id: str
|
|
60
|
+
correct: bool
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class IrtFittedModel(FittedModel):
|
|
65
|
+
items: List[IrtItemParams] = field(default_factory=list)
|
|
66
|
+
learners: List[IrtLearnerResult] = field(default_factory=list)
|
|
67
|
+
responses: List[IrtResponseTriple] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _sigmoid(z: float) -> float:
|
|
71
|
+
"""Numerically stable logistic sigmoid."""
|
|
72
|
+
if z >= 0:
|
|
73
|
+
e = math.exp(-z)
|
|
74
|
+
return 1.0 / (1.0 + e)
|
|
75
|
+
e = math.exp(z)
|
|
76
|
+
return e / (1.0 + e)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def probability_correct(theta: float, a: float, b: float) -> float:
|
|
80
|
+
"""P(correct) under the 2-parameter logistic model."""
|
|
81
|
+
return _sigmoid(a * (theta - b))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class _JmleResult:
|
|
86
|
+
theta: Dict[str, float]
|
|
87
|
+
a: Dict[str, float]
|
|
88
|
+
b: Dict[str, float]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _fit_jmle(
|
|
92
|
+
learner_ids: List[str],
|
|
93
|
+
item_ids: List[str],
|
|
94
|
+
responses: List[tuple],
|
|
95
|
+
config: IrtConfig,
|
|
96
|
+
) -> _JmleResult:
|
|
97
|
+
"""
|
|
98
|
+
Joint maximum-likelihood estimation for the 2PL IRT model via batch
|
|
99
|
+
gradient ascent on the log-likelihood, with a small L2 prior
|
|
100
|
+
(regularizing theta/b toward 0 and a toward 1) that keeps estimates
|
|
101
|
+
finite for learners or skills with an all-correct or all-incorrect
|
|
102
|
+
record.
|
|
103
|
+
|
|
104
|
+
The 2PL model is only identified up to an additive shift and
|
|
105
|
+
multiplicative scale of theta (z = a*(theta-b) is unchanged by
|
|
106
|
+
shifting theta and b by the same constant, or by scaling theta/b by s
|
|
107
|
+
while dividing a by s). To pin down a single solution, after every
|
|
108
|
+
iteration the theta distribution is re-centered to mean 0 and
|
|
109
|
+
re-scaled to standard deviation 1, applying the matching inverse
|
|
110
|
+
transform to b and a so every predicted probability is left exactly
|
|
111
|
+
unchanged. This is the standard way JMLE implementations fix the
|
|
112
|
+
person-parameter scale.
|
|
113
|
+
"""
|
|
114
|
+
n_learners = len(learner_ids)
|
|
115
|
+
n_items = len(item_ids)
|
|
116
|
+
|
|
117
|
+
theta = [0.0] * n_learners
|
|
118
|
+
a = [1.0] * n_items
|
|
119
|
+
b = [0.0] * n_items
|
|
120
|
+
|
|
121
|
+
iterations = config.iterations
|
|
122
|
+
learning_rate = config.learning_rate
|
|
123
|
+
regularization = config.regularization
|
|
124
|
+
|
|
125
|
+
# Gradients are averaged per learner/item (not summed) so the
|
|
126
|
+
# effective step size does not depend on how many responses a learner
|
|
127
|
+
# or item happens to have -- with raw summed gradients, a learner with
|
|
128
|
+
# hundreds of responses would take enormous steps relative to one with
|
|
129
|
+
# a handful, making a single learning_rate unstable across datasets of
|
|
130
|
+
# different size.
|
|
131
|
+
response_count_by_learner = [0.0] * n_learners
|
|
132
|
+
response_count_by_item = [0.0] * n_items
|
|
133
|
+
for learner_index, item_index, _correct in responses:
|
|
134
|
+
response_count_by_learner[learner_index] += 1
|
|
135
|
+
response_count_by_item[item_index] += 1
|
|
136
|
+
|
|
137
|
+
for _ in range(iterations):
|
|
138
|
+
grad_theta = [0.0] * n_learners
|
|
139
|
+
grad_a = [0.0] * n_items
|
|
140
|
+
grad_b = [0.0] * n_items
|
|
141
|
+
|
|
142
|
+
for learner_index, item_index, correct in responses:
|
|
143
|
+
th = theta[learner_index]
|
|
144
|
+
ai = a[item_index]
|
|
145
|
+
bi = b[item_index]
|
|
146
|
+
p = _sigmoid(ai * (th - bi))
|
|
147
|
+
residual = (1.0 if correct else 0.0) - p # dLogLik/dz
|
|
148
|
+
|
|
149
|
+
grad_theta[learner_index] += ai * residual
|
|
150
|
+
grad_b[item_index] -= ai * residual
|
|
151
|
+
grad_a[item_index] += (th - bi) * residual
|
|
152
|
+
|
|
153
|
+
for i in range(n_learners):
|
|
154
|
+
count = response_count_by_learner[i] or 1.0
|
|
155
|
+
grad = grad_theta[i] / count - regularization * theta[i]
|
|
156
|
+
theta[i] = theta[i] + learning_rate * grad
|
|
157
|
+
|
|
158
|
+
for j in range(n_items):
|
|
159
|
+
count = response_count_by_item[j] or 1.0
|
|
160
|
+
grad_bj = grad_b[j] / count - regularization * b[j]
|
|
161
|
+
b[j] = b[j] + learning_rate * grad_bj
|
|
162
|
+
grad_aj = grad_a[j] / count - regularization * (a[j] - 1.0)
|
|
163
|
+
next_a = a[j] + learning_rate * grad_aj
|
|
164
|
+
# Discrimination must stay positive; floor it well away from zero.
|
|
165
|
+
a[j] = max(next_a, 0.05)
|
|
166
|
+
|
|
167
|
+
# Fix the theta location/scale gauge freedom (see docstring above).
|
|
168
|
+
mean = sum(theta) / n_learners if n_learners else 0.0
|
|
169
|
+
variance = sum((t - mean) ** 2 for t in theta) / n_learners if n_learners else 0.0
|
|
170
|
+
std = math.sqrt(variance)
|
|
171
|
+
if std > 1e-6:
|
|
172
|
+
theta = [(t - mean) / std for t in theta]
|
|
173
|
+
for j in range(n_items):
|
|
174
|
+
b[j] = (b[j] - mean) / std
|
|
175
|
+
a[j] = a[j] * std
|
|
176
|
+
|
|
177
|
+
theta_map = {learner_ids[i]: theta[i] for i in range(n_learners)}
|
|
178
|
+
a_map = {item_ids[j]: a[j] for j in range(n_items)}
|
|
179
|
+
b_map = {item_ids[j]: b[j] for j in range(n_items)}
|
|
180
|
+
return _JmleResult(theta=theta_map, a=a_map, b=b_map)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class IrtModel:
|
|
184
|
+
"""
|
|
185
|
+
Item Response Theory (2-parameter logistic) scoring model. Treats
|
|
186
|
+
each distinct skillId as one "item": every response event for a skill
|
|
187
|
+
is an observation of that item, and the model jointly estimates one
|
|
188
|
+
ability (theta) per learner and one (discrimination, difficulty) pair
|
|
189
|
+
per skill. Implements the ScoringModel protocol so it is
|
|
190
|
+
interchangeable with BKT.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
name = "irt"
|
|
194
|
+
|
|
195
|
+
def __init__(self, config: Optional[IrtConfig] = None) -> None:
|
|
196
|
+
self.config = config or IrtConfig()
|
|
197
|
+
|
|
198
|
+
def fit(self, events: List[ResponseEvent]) -> IrtFittedModel:
|
|
199
|
+
learner_ids: List[str] = []
|
|
200
|
+
seen_learners = set()
|
|
201
|
+
item_ids: List[str] = []
|
|
202
|
+
seen_items = set()
|
|
203
|
+
for e in events:
|
|
204
|
+
if e.learner_id not in seen_learners:
|
|
205
|
+
seen_learners.add(e.learner_id)
|
|
206
|
+
learner_ids.append(e.learner_id)
|
|
207
|
+
if e.skill_id not in seen_items:
|
|
208
|
+
seen_items.add(e.skill_id)
|
|
209
|
+
item_ids.append(e.skill_id)
|
|
210
|
+
|
|
211
|
+
learner_index = {lid: i for i, lid in enumerate(learner_ids)}
|
|
212
|
+
item_index = {sid: i for i, sid in enumerate(item_ids)}
|
|
213
|
+
|
|
214
|
+
responses = [(learner_index[e.learner_id], item_index[e.skill_id], e.correct) for e in events]
|
|
215
|
+
|
|
216
|
+
response_counts: Dict[str, int] = {}
|
|
217
|
+
for e in events:
|
|
218
|
+
response_counts[e.learner_id] = response_counts.get(e.learner_id, 0) + 1
|
|
219
|
+
|
|
220
|
+
if not learner_ids or not item_ids:
|
|
221
|
+
return IrtFittedModel(model_name="irt", items=[], learners=[], responses=[])
|
|
222
|
+
|
|
223
|
+
fitted = _fit_jmle(learner_ids, item_ids, responses, self.config)
|
|
224
|
+
|
|
225
|
+
items = [IrtItemParams(skill_id=sid, a=fitted.a.get(sid, 1.0), b=fitted.b.get(sid, 0.0)) for sid in item_ids]
|
|
226
|
+
learners = [
|
|
227
|
+
IrtLearnerResult(
|
|
228
|
+
learner_id=lid,
|
|
229
|
+
theta=fitted.theta.get(lid, 0.0),
|
|
230
|
+
response_count=response_counts.get(lid, 0),
|
|
231
|
+
)
|
|
232
|
+
for lid in learner_ids
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
return IrtFittedModel(
|
|
236
|
+
model_name="irt",
|
|
237
|
+
items=items,
|
|
238
|
+
learners=learners,
|
|
239
|
+
responses=[IrtResponseTriple(learner_id=e.learner_id, skill_id=e.skill_id, correct=e.correct) for e in events],
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def score(self, fitted_model: IrtFittedModel) -> MasteryReport:
|
|
243
|
+
items_by_skill = {item.skill_id: item for item in fitted_model.items}
|
|
244
|
+
skill_ids_by_learner: Dict[str, List[str]] = {}
|
|
245
|
+
seen_pairs = set()
|
|
246
|
+
response_count_by_learner_skill: Dict[str, int] = {}
|
|
247
|
+
|
|
248
|
+
for response in fitted_model.responses:
|
|
249
|
+
key = f"{response.learner_id}::{response.skill_id}"
|
|
250
|
+
pair_key = (response.learner_id, response.skill_id)
|
|
251
|
+
if pair_key not in seen_pairs:
|
|
252
|
+
seen_pairs.add(pair_key)
|
|
253
|
+
skill_ids_by_learner.setdefault(response.learner_id, []).append(response.skill_id)
|
|
254
|
+
response_count_by_learner_skill[key] = response_count_by_learner_skill.get(key, 0) + 1
|
|
255
|
+
|
|
256
|
+
learners: List[MasteryLearnerEntry] = []
|
|
257
|
+
for learner_result in fitted_model.learners:
|
|
258
|
+
skill_ids = skill_ids_by_learner.get(learner_result.learner_id, [])
|
|
259
|
+
skills: List[MasterySkillEntry] = []
|
|
260
|
+
for skill_id in skill_ids:
|
|
261
|
+
item = items_by_skill.get(skill_id)
|
|
262
|
+
a = item.a if item else 1.0
|
|
263
|
+
b = item.b if item else 0.0
|
|
264
|
+
key = f"{learner_result.learner_id}::{skill_id}"
|
|
265
|
+
skills.append(
|
|
266
|
+
MasterySkillEntry(
|
|
267
|
+
skill_id=skill_id,
|
|
268
|
+
metric="ability_theta",
|
|
269
|
+
value=learner_result.theta,
|
|
270
|
+
response_count=response_count_by_learner_skill.get(key, 0),
|
|
271
|
+
details={
|
|
272
|
+
"item_discrimination": a,
|
|
273
|
+
"item_difficulty": b,
|
|
274
|
+
"predicted_probability_correct": probability_correct(learner_result.theta, a, b),
|
|
275
|
+
},
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
learners.append(MasteryLearnerEntry(learner_id=learner_result.learner_id, skills=skills))
|
|
279
|
+
|
|
280
|
+
return MasteryReport(
|
|
281
|
+
model="irt",
|
|
282
|
+
generated_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
283
|
+
learners=learners,
|
|
284
|
+
meta={"items": fitted_model.items},
|
|
285
|
+
)
|
masterytrace/py.typed
ADDED
|
File without changes
|