import math import logging _logger = logging.getLogger(__name__) class CatEngine: """Computerized Adaptive Testing engine using IRT 3PL model.""" SEM_THRESHOLD = 0.3 MAX_QUESTIONS = 40 MIN_QUESTIONS = 10 THETA_MIN = -4.0 THETA_MAX = 4.0 @staticmethod def irt_probability(theta, a, b, c): """3PL IRT probability of correct response.""" exponent = -a * (theta - b) exponent = max(min(exponent, 20), -20) return c + (1 - c) / (1 + math.exp(exponent)) @staticmethod def fisher_information(theta, a, b, c): """Fisher information for a single item at given theta.""" p = CatEngine.irt_probability(theta, a, b, c) q = 1 - p if p <= c or q <= 0: return 0.0 numerator = a**2 * (p - c)**2 * q denominator = (1 - c)**2 * p if denominator == 0: return 0.0 return numerator / denominator @staticmethod def select_next_question(theta, available_questions): """Select the question with maximum Fisher information at current theta. available_questions: list of dicts with keys: id, irt_a, irt_b, irt_c Returns: the selected question dict, or None if empty. """ if not available_questions: return None best_question = None best_info = -1.0 for q in available_questions: a = q.get('irt_a', 1.0) b = q.get('irt_b', 0.0) c = q.get('irt_c', 0.25) info = CatEngine.fisher_information(theta, a, b, c) if info > best_info: best_info = info best_question = q return best_question @staticmethod def update_theta(theta, responses, questions): """Update theta using Newton-Raphson MLE. responses: list of 0/1 (incorrect/correct) questions: list of dicts with irt_a, irt_b, irt_c Returns: updated theta, standard error of measurement """ if not responses: return theta, 1.0 current_theta = theta for _iteration in range(30): numerator = 0.0 denominator = 0.0 for resp, q in zip(responses, questions): a = q.get('irt_a', 1.0) b = q.get('irt_b', 0.0) c = q.get('irt_c', 0.25) p = CatEngine.irt_probability(current_theta, a, b, c) q_val = 1 - p if p <= c: continue w = a * (p - c) / ((1 - c) * p) numerator += w * (resp - p) denominator += w**2 * p * q_val if abs(denominator) < 1e-10: break delta = numerator / denominator current_theta += delta current_theta = max(CatEngine.THETA_MIN, min(CatEngine.THETA_MAX, current_theta)) if abs(delta) < 0.001: break total_info = 0.0 for q in questions: a = q.get('irt_a', 1.0) b = q.get('irt_b', 0.0) c = q.get('irt_c', 0.25) total_info += CatEngine.fisher_information(current_theta, a, b, c) sem = 1.0 / math.sqrt(total_info) if total_info > 0 else 1.0 return current_theta, sem @staticmethod def check_stopping(sem, questions_answered): """Check if CAT should stop. Returns: (should_stop, reason) """ if questions_answered >= CatEngine.MAX_QUESTIONS: return True, 'max_questions_reached' if sem < CatEngine.SEM_THRESHOLD and questions_answered >= CatEngine.MIN_QUESTIONS: return True, 'sem_converged' return False, None @staticmethod def estimate_ability_eap(responses, questions, prior_mean=0.0, prior_sd=1.0, num_points=61): """Expected A Posteriori (EAP) ability estimation. More robust than MLE for short tests. """ theta_range = [ CatEngine.THETA_MIN + i * (CatEngine.THETA_MAX - CatEngine.THETA_MIN) / (num_points - 1) for i in range(num_points) ] log_posteriors = [] for theta in theta_range: log_likelihood = 0.0 for resp, q in zip(responses, questions): a = q.get('irt_a', 1.0) b = q.get('irt_b', 0.0) c = q.get('irt_c', 0.25) p = CatEngine.irt_probability(theta, a, b, c) p = max(min(p, 0.9999), 0.0001) if resp == 1: log_likelihood += math.log(p) else: log_likelihood += math.log(1 - p) log_prior = -0.5 * ((theta - prior_mean) / prior_sd) ** 2 log_posteriors.append(log_likelihood + log_prior) max_log = max(log_posteriors) posteriors = [math.exp(lp - max_log) for lp in log_posteriors] total = sum(posteriors) posteriors = [p / total for p in posteriors] eap = sum(t * p for t, p in zip(theta_range, posteriors)) eap_var = sum(p * (t - eap) ** 2 for t, p in zip(theta_range, posteriors)) sem = math.sqrt(eap_var) return eap, sem