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.
@@ -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