| """tutor/adaptive.py — LearnerState with Bayesian Knowledge Tracing.""" |
| from __future__ import annotations |
| import random |
| from collections import defaultdict |
| from typing import List, Dict, Any |
|
|
| BKT_DEFAULTS = {"p_init": 0.30, "p_learn": 0.10, "p_guess": 0.20, "p_slip": 0.10} |
|
|
| AGE_CONFIG = { |
| 5: {"diff_min": 1, "diff_max": 3, "target_acc": 0.70}, |
| 6: {"diff_min": 1, "diff_max": 4, "target_acc": 0.72}, |
| 7: {"diff_min": 2, "diff_max": 6, "target_acc": 0.75}, |
| 8: {"diff_min": 3, "diff_max": 7, "target_acc": 0.75}, |
| 9: {"diff_min": 4, "diff_max": 9, "target_acc": 0.78}, |
| } |
|
|
| DYSC_STREAK_THRESHOLD = 5 |
| DYSC_MIN_ATTEMPTS = 8 |
|
|
|
|
| class SkillBKT: |
| def __init__(self, skill: str, params: Dict | None = None): |
| self.skill = skill |
| p = params or BKT_DEFAULTS |
| self.p_init = p["p_init"] |
| self.p_learn = p["p_learn"] |
| self.p_guess = p["p_guess"] |
| self.p_slip = p["p_slip"] |
| self.p_know = self.p_init |
| self.attempts = 0 |
| self.correct = 0 |
| self.streak_wrong = 0 |
|
|
| def update(self, is_correct: bool): |
| self.attempts += 1 |
| if is_correct: |
| self.correct += 1 |
| self.streak_wrong = 0 |
| else: |
| self.streak_wrong += 1 |
| p_ok = (1 - self.p_slip) if is_correct else self.p_slip |
| p_nok = self.p_guess if is_correct else (1 - self.p_guess) |
| num = p_ok * self.p_know |
| den = num + p_nok * (1 - self.p_know) |
| p_kno = num / (den + 1e-9) |
| self.p_know = p_kno + (1 - p_kno) * self.p_learn |
|
|
| def mastery(self) -> float: |
| return self.p_know |
|
|
| def to_dict(self): |
| return {k: getattr(self, k) for k in |
| ("skill","p_know","p_init","p_learn","p_guess","p_slip", |
| "attempts","correct","streak_wrong")} |
|
|
| @classmethod |
| def from_dict(cls, d): |
| obj = cls(d["skill"], {k: d[k] for k in ("p_init","p_learn","p_guess","p_slip")}) |
| obj.p_know = d["p_know"] |
| obj.attempts = d["attempts"] |
| obj.correct = d["correct"] |
| obj.streak_wrong = d.get("streak_wrong", 0) |
| return obj |
|
|
|
|
| class LearnerState: |
| def __init__(self, learner_id: str, lang: str = "en", age: int = 7): |
| self.learner_id = learner_id |
| self.lang = lang |
| self.age = max(5, min(9, age)) |
| self.bkt: Dict[str, SkillBKT] = {} |
| self._history: List[Dict] = [] |
|
|
| @property |
| def age_config(self): |
| return AGE_CONFIG.get(self.age, AGE_CONFIG[7]) |
|
|
| def _bkt(self, skill: str) -> SkillBKT: |
| if skill not in self.bkt: |
| self.bkt[skill] = SkillBKT(skill) |
| return self.bkt[skill] |
|
|
| def record_response(self, item: Dict, is_correct: bool): |
| self._bkt(item.get("skill", "unknown")).update(is_correct) |
| self._history.append({"item_id": item.get("id"), "correct": is_correct}) |
|
|
| def mastery(self, skill: str) -> float: |
| return self._bkt(skill).mastery() |
|
|
| def select_next_item(self, all_items: List[Dict], use_bkt: bool = True) -> Dict: |
| cfg = self.age_config |
| by_skill: Dict[str, List[Dict]] = defaultdict(list) |
| for item in all_items: |
| d = item.get("difficulty", 5) |
| if cfg["diff_min"] <= d <= cfg["diff_max"]: |
| by_skill[item.get("skill", "unknown")].append(item) |
| if not by_skill: |
| return random.choice(all_items) |
| skills = sorted(by_skill.keys(), key=lambda s: self.mastery(s)) if use_bkt \ |
| else list(by_skill.keys()) |
| for skill in skills: |
| cands = by_skill[skill] |
| if cands: |
| m = self.mastery(skill) |
| t = cfg["diff_min"] + m * (cfg["diff_max"] - cfg["diff_min"]) |
| cands.sort(key=lambda i: abs(i.get("difficulty", 5) - t)) |
| return cands[0] |
| return random.choice(all_items) |
|
|
| def dyscalculia_warning(self) -> List[str]: |
| return [s for s, b in self.bkt.items() |
| if b.attempts >= DYSC_MIN_ATTEMPTS and b.streak_wrong >= DYSC_STREAK_THRESHOLD] |
|
|
| def to_dict(self): |
| return { |
| "learner_id": self.learner_id, "lang": self.lang, "age": self.age, |
| "bkt": {k: v.to_dict() for k, v in self.bkt.items()}, |
| "history_len": len(self._history), |
| } |
|
|
| @classmethod |
| def from_dict(cls, d): |
| obj = cls(d["learner_id"], d.get("lang","en"), d.get("age",7)) |
| for skill, bd in d.get("bkt", {}).items(): |
| obj.bkt[skill] = SkillBKT.from_dict(bd) |
| return obj |
|
|