"""Classical item analysis for multiple-choice tests. Everything here is plain Python and runs without an API key. The numbers are the standard ones from classical test theory: difficulty (p) share of learners who answered correctly discrimination (r) corrected point-biserial: how well the item separates stronger and weaker learners (item vs. rest of test) D index upper-group p minus lower-group p (top and bottom 27%) distractor stats who picked each option, and whether the people picking a wrong option tend to score high or low KR-20 reliability of the whole test """ from __future__ import annotations import csv import json import math from dataclasses import dataclass, field from pathlib import Path # Thresholds used for flags. These are common rules of thumb, not laws. TOO_EASY = 0.90 TOO_HARD = 0.30 WEAK_DISCRIMINATION = 0.20 NONFUNCTIONAL_DISTRACTOR = 0.05 GROUP_FRACTION = 0.27 @dataclass class OptionStats: option: str count: int share: float upper_share: float lower_share: float point_biserial: float | None @dataclass class ItemStats: item_id: str key: str difficulty: float discrimination: float | None d_index: float alpha_if_deleted: float | None options: list[OptionStats] flags: list[str] = field(default_factory=list) @dataclass class TestStats: n_learners: int n_items: int mean_score: float sd_score: float kr20: float | None items: list[ItemStats] def load_quiz(path: Path) -> dict: return json.loads(Path(path).read_text()) def load_responses(path: Path) -> list[dict[str, str]]: with Path(path).open(newline="") as f: return list(csv.DictReader(f)) def mean(xs: list[float]) -> float: return sum(xs) / len(xs) def pstdev(xs: list[float]) -> float: m = mean(xs) return math.sqrt(sum((x - m) ** 2 for x in xs) / len(xs)) def pearson(xs: list[float], ys: list[float]) -> float | None: """Pearson correlation, or None when either list has no variance.""" sx, sy = pstdev(xs), pstdev(ys) if sx == 0 or sy == 0: return None mx, my = mean(xs), mean(ys) cov = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / len(xs) return cov / (sx * sy) def kr20(score_matrix: list[list[int]]) -> float | None: """Kuder-Richardson 20 reliability for right/wrong items.""" k = len(score_matrix[0]) if k < 2: return None totals = [sum(row) for row in score_matrix] var_total = pstdev(totals) ** 2 if var_total == 0: return None pq = 0.0 for j in range(k): p = mean([row[j] for row in score_matrix]) pq += p * (1 - p) return (k / (k - 1)) * (1 - pq / var_total) def analyze(quiz: dict, responses: list[dict[str, str]]) -> TestStats: items = quiz["items"] item_ids = [item["id"] for item in items] keys = {item["id"]: item["key"] for item in items} # 1 = correct, 0 = wrong, one row per learner scores = [[int(r[i].strip().upper() == keys[i]) for i in item_ids] for r in responses] totals = [sum(row) for row in scores] n = len(responses) # Upper and lower groups by total score (top and bottom 27%) group_size = max(1, round(n * GROUP_FRACTION)) order = sorted(range(n), key=lambda idx: totals[idx]) lower = set(order[:group_size]) upper = set(order[-group_size:]) item_stats = [] for j, item in enumerate(items): item_id = item["id"] col = [row[j] for row in scores] rest = [totals[idx] - col[idx] for idx in range(n)] chosen = [r[item_id].strip().upper() for r in responses] p = mean(col) upper_p = mean([col[idx] for idx in upper]) lower_p = mean([col[idx] for idx in lower]) options = [] for letter in item["options"]: picked = [1 if c == letter else 0 for c in chosen] options.append( OptionStats( option=letter, count=sum(picked), share=mean(picked), upper_share=mean([picked[idx] for idx in upper]), lower_share=mean([picked[idx] for idx in lower]), # option vs. rest-of-test score: should be positive for # the key and negative for good distractors point_biserial=pearson(picked, rest), ) ) others = [[row[m] for m in range(len(items)) if m != j] for row in scores] stats = ItemStats( item_id=item_id, key=item["key"], difficulty=p, discrimination=pearson(col, rest), d_index=upper_p - lower_p, alpha_if_deleted=kr20(others), options=options, ) stats.flags = flag_item(stats) item_stats.append(stats) return TestStats( n_learners=n, n_items=len(items), mean_score=mean(totals), sd_score=pstdev(totals), kr20=kr20(scores), items=item_stats, ) def flag_item(s: ItemStats) -> list[str]: """Plain-language flags for anything that looks off.""" flags = [] if s.difficulty > TOO_EASY: flags.append(f"very easy: {s.difficulty:.0%} answered correctly") if s.difficulty < TOO_HARD: flags.append(f"very hard: only {s.difficulty:.0%} answered correctly") r = s.discrimination if r is not None and r < 0: flags.append(f"negative discrimination (r = {r:.2f}): weaker learners did better than stronger ones") elif r is not None and r < WEAK_DISCRIMINATION: flags.append(f"weak discrimination (r = {r:.2f})") key_upper = next(o.upper_share for o in s.options if o.option == s.key) for o in s.options: if o.option == s.key: continue if o.share < NONFUNCTIONAL_DISTRACTOR: flags.append(f"option {o.option} barely works as a distractor ({o.count} picked it)") if o.point_biserial is not None and o.point_biserial > 0.10: flags.append( f"option {o.option} attracts stronger learners (r = {o.point_biserial:.2f}): " "possible miskey or a second defensible answer" ) if o.upper_share > key_upper: flags.append(f"top scorers picked option {o.option} more often than the key") return flags