| """ |
| Exam Predictor v3 — Chapter-level 3-stage model with constraint-based reranking. |
| |
| Architecture: |
| Stage 1: Appearance model — P(chapter appears in paper) |
| Stage 2: Weightage model — Expected questions if chapter appears |
| Stage 3: Format model — Likely question types |
| |
| Post-processing: |
| - Subject-balanced reranking with quotas |
| - Unique chapter enforcement (no duplicate slots) |
| - Diversification penalty for correlated chapters |
| |
| Evaluation: |
| - Coverage@K (fraction of actual paper covered) |
| - Heavy-topic recall (chapters with 3+ questions) |
| - Subject coverage balance |
| - Rank correlation (predicted importance vs actual) |
| """ |
|
|
| import numpy as np |
| import pandas as pd |
| from collections import Counter |
| from utils.db import get_questions_df |
| from analysis.trend_analyzer import detect_cycles |
| from data.historical_events import NEET_2024_REMOVED, NEET_2024_ADDED, JEE_2024_REMOVED |
|
|
| HOLDOUT_YEARS = {2024, 2025, 2026} |
|
|
| |
| |
| |
|
|
| |
| CHAPTER_ALIASES = { |
| "heat and thermodynamics": "thermodynamics", |
| "heat & thermodynamics": "thermodynamics", |
| "thermal properties of matter": "thermodynamics", |
| "atoms and nuclei": "atoms and nuclei", |
| "atoms": "atoms and nuclei", |
| "nuclei": "atoms and nuclei", |
| "equilibrum": "equilibrium", |
| "chemical equilibrium": "equilibrium", |
| "ionic equilibrium": "equilibrium", |
| "ray optics and optical instruments": "ray optics", |
| "ray optics": "ray optics", |
| "geometrical optics": "ray optics", |
| "wave optics": "wave optics", |
| "semiconductor electronics": "semiconductor electronics", |
| "semiconductor and p n junction diode": "semiconductor electronics", |
| "p block elements": "p block elements", |
| "p-block elements": "p block elements", |
| "d and f block elements": "d and f block elements", |
| "d-block and f-block elements": "d and f block elements", |
| "d block elements": "d and f block elements", |
| "coordination compounds": "coordination compounds", |
| "coordination chemistry": "coordination compounds", |
| "units and measurements": "units and measurements", |
| "units and measurement": "units and measurements", |
| "physical world and measurement": "units and measurements", |
| "electrostatic potential and capacitance": "electrostatics", |
| "electric charges and fields": "electrostatics", |
| "electrostatics": "electrostatics", |
| "electromagnetic induction": "electromagnetic induction", |
| "electromagnetic induction and alternating current": "electromagnetic induction", |
| "alternating current": "alternating current", |
| "ac circuits and power in ac circuits": "alternating current", |
| "moving charges and magnetism": "magnetism", |
| "magnetism and matter": "magnetism", |
| "chemical kinetics": "chemical kinetics", |
| "reaction kinetics": "chemical kinetics", |
| } |
|
|
|
|
| def _normalize_chapter(name): |
| """Normalize chapter name using alias map.""" |
| key = name.strip().lower() |
| return CHAPTER_ALIASES.get(key, name) |
|
|
|
|
| def _syllabus_status(topic, exam): |
| """Determine syllabus status for a chapter.""" |
| topic_lower = topic.lower() |
| removed_list = NEET_2024_REMOVED if exam == "NEET" else JEE_2024_REMOVED if exam else [] |
| added_list = NEET_2024_ADDED if exam == "NEET" else [] |
|
|
| for r in removed_list: |
| if r.lower() in topic_lower or topic_lower in r.lower(): |
| return "REMOVED", 0.0 |
| for a in added_list: |
| if a.lower() in topic_lower or topic_lower in a.lower(): |
| return "NEW", 0.5 |
| return "RETAINED", 1.0 |
|
|
|
|
| |
| |
| |
|
|
| def _appearance_probability(years_appeared, total_years_range, target_year, max_year, decay=1.5): |
| """ |
| Estimate P(chapter appears in target year). |
| Uses recency-weighted frequency + gap-return + trend. |
| """ |
| if not years_appeared: |
| return 0.0, {} |
|
|
| signals = {} |
|
|
| |
| rwf = sum(1.0 / ((target_year - y) ** decay + 1) for y in years_appeared) |
| max_possible = sum(1.0 / ((target_year - y) ** decay + 1) for y in range(total_years_range[0], total_years_range[1] + 1)) |
| rwf_norm = min(rwf / (max_possible + 0.01), 1.0) |
| signals["recency_freq"] = rwf_norm |
|
|
| |
| year_span = total_years_range[1] - total_years_range[0] + 1 |
| appearance_rate = len(set(years_appeared)) / year_span |
| signals["appearance_rate"] = min(appearance_rate, 1.0) |
|
|
| |
| recent_3 = sum(1 for y in years_appeared if y >= max_year - 2) |
| recent_5 = sum(1 for y in years_appeared if y >= max_year - 4) |
| signals["recent_3yr"] = min(recent_3 / 3, 1.0) |
| signals["recent_5yr"] = min(recent_5 / 5, 1.0) |
|
|
| |
| gap = target_year - max(years_appeared) |
| gaps = sorted(set(years_appeared)) |
| inter_gaps = [gaps[i+1] - gaps[i] for i in range(len(gaps)-1)] |
| mean_gap = np.mean(inter_gaps) if inter_gaps else year_span |
| gap_ratio = gap / (mean_gap + 0.1) |
| gap_return = min(gap_ratio, 2.0) / 2.0 |
| signals["gap_return"] = gap_return |
|
|
| |
| recent_window = list(range(max(total_years_range[0], max_year - 9), max_year + 1)) |
| year_counts = Counter(years_appeared) |
| y_vals = [year_counts.get(yr, 0) for yr in recent_window] |
| if len(y_vals) >= 3 and sum(y_vals) > 0: |
| x = np.arange(len(y_vals)) |
| slope = np.polyfit(x, y_vals, 1)[0] |
| slope_norm = min(max((slope + 0.5) / 1.0, 0), 1.0) |
| else: |
| slope_norm = 0.5 |
| signals["trend_slope"] = slope_norm |
|
|
| |
| cycle_score = 0.0 |
| if inter_gaps and len(set(years_appeared)) >= 4: |
| avg_gap = np.mean(inter_gaps) |
| variance = np.var(inter_gaps) |
| if variance <= 1.5 and avg_gap > 0: |
| years_since = target_year - max(years_appeared) |
| remainder = years_since % round(avg_gap) |
| if remainder == 0: |
| cycle_score = 1.0 |
| elif remainder <= 1 or (round(avg_gap) - remainder) <= 1: |
| cycle_score = 0.5 |
| signals["cycle_match"] = cycle_score |
|
|
| |
| weights = { |
| "recency_freq": 0.25, |
| "appearance_rate": 0.20, |
| "recent_3yr": 0.15, |
| "recent_5yr": 0.05, |
| "gap_return": 0.15, |
| "trend_slope": 0.12, |
| "cycle_match": 0.08, |
| } |
|
|
| score = sum(signals[k] * weights[k] for k in weights) |
| return min(score * 1.8, 0.99), signals |
|
|
|
|
| |
| |
| |
|
|
| def _expected_questions(qs_per_year, years_appeared, target_year, max_year): |
| """ |
| Predict expected number of questions if chapter appears. |
| Returns (expected_qs, min_qs, max_qs, confidence). |
| """ |
| if not qs_per_year: |
| return 1, 1, 2, 0.0 |
|
|
| values = list(qs_per_year.values()) |
| mean_qs = np.mean(values) |
| median_qs = np.median(values) |
|
|
| |
| weighted_sum = 0 |
| weight_total = 0 |
| for yr, count in qs_per_year.items(): |
| w = 1.0 / ((target_year - yr) ** 0.5 + 1) |
| weighted_sum += count * w |
| weight_total += w |
| recency_avg = weighted_sum / weight_total if weight_total > 0 else mean_qs |
|
|
| |
| recent_vals = [v for y, v in qs_per_year.items() if y >= max_year - 4] |
| recent_avg = np.mean(recent_vals) if recent_vals else mean_qs |
|
|
| |
| expected = 0.50 * recency_avg + 0.30 * recent_avg + 0.20 * median_qs |
|
|
| |
| cv = np.std(values) / mean_qs if mean_qs > 0 else 1.0 |
| weightage_confidence = max(0, 1 - cv) |
|
|
| |
| q25 = max(1, int(np.percentile(values, 25))) |
| q75 = int(np.percentile(values, 75)) |
|
|
| return round(expected, 1), q25, max(q75, q25 + 1), round(weightage_confidence, 2) |
|
|
|
|
| |
| |
| |
|
|
| def _predict_format(question_types, difficulty_list, recent_difficulties): |
| """Predict likely question formats and difficulty.""" |
| |
| if question_types: |
| type_ranked = sorted(question_types.items(), key=lambda x: -x[1]) |
| likely_types = [t[0] for t in type_ranked[:3]] |
| dominant_pct = type_ranked[0][1] / sum(question_types.values()) |
| else: |
| likely_types = ["MCQ_single"] |
| dominant_pct = 1.0 |
|
|
| |
| if recent_difficulties: |
| likely_diff = round(np.mean(recent_difficulties), 1) |
| elif difficulty_list: |
| likely_diff = round(np.mean(difficulty_list), 1) |
| else: |
| likely_diff = 3.0 |
|
|
| return likely_types, likely_diff, round(dominant_pct, 2) |
|
|
|
|
| |
| |
| |
|
|
| def _confidence_score(appearance_prob, weightage_confidence, appearances, syl_status, |
| recent_3yr, trend_slope): |
| """ |
| Composite confidence score separate from probability. |
| A topic can have high probability but low confidence (volatile) |
| or high confidence but low probability (stable but rare). |
| """ |
| factors = { |
| "data_depth": min(appearances / 15, 1.0), |
| "weightage_stability": weightage_confidence, |
| "recent_evidence": min(recent_3yr / 2, 1.0), |
| "trend_clarity": abs(trend_slope - 0.5) * 2, |
| "syllabus_certainty": 1.0 if syl_status == "RETAINED" else 0.5 if syl_status == "MODIFIED" else 0.3, |
| } |
|
|
| weights = {"data_depth": 0.30, "weightage_stability": 0.25, "recent_evidence": 0.20, |
| "trend_clarity": 0.10, "syllabus_certainty": 0.15} |
|
|
| score = sum(factors[k] * weights[k] for k in weights) |
|
|
| if score >= 0.65: |
| label = "HIGH" |
| elif score >= 0.45: |
| label = "MEDIUM" |
| elif score >= 0.25: |
| label = "LOW" |
| else: |
| label = "SPECULATIVE" |
|
|
| return round(score, 3), label |
|
|
|
|
| |
| |
| |
|
|
| |
| SUBJECT_QUOTAS = { |
| "NEET": {"Biology": 0.50, "Physics": 0.25, "Chemistry": 0.25}, |
| "JEE Main": {"Physics": 0.33, "Chemistry": 0.33, "Mathematics": 0.34}, |
| "JEE Advanced": {"Physics": 0.33, "Chemistry": 0.33, "Mathematics": 0.34}, |
| } |
|
|
| |
| EXAM_VALID_SUBJECTS = { |
| "NEET": {"Biology", "Physics", "Chemistry"}, |
| "JEE Main": {"Physics", "Chemistry", "Mathematics"}, |
| "JEE Advanced": {"Physics", "Chemistry", "Mathematics"}, |
| } |
|
|
|
|
| def _subject_balanced_rerank(predictions, exam, top_k=50): |
| """ |
| Rerank predictions with subject quotas. |
| Ensures each subject gets proportional representation. |
| Also enforces hard subject guard to prevent cross-exam leakage |
| (e.g. Mathematics in NEET, Biology in JEE). |
| """ |
| |
| valid_subjects = EXAM_VALID_SUBJECTS.get(exam, set()) |
| if valid_subjects: |
| predictions = [p for p in predictions if p["subject"] in valid_subjects] |
|
|
| quotas = SUBJECT_QUOTAS.get(exam, {}) |
| if not quotas: |
| |
| subjects = list(set(p["subject"] for p in predictions)) |
| quotas = {s: 1.0 / len(subjects) for s in subjects} |
|
|
| |
| slots = {s: max(3, int(top_k * q)) for s, q in quotas.items()} |
|
|
| |
| total = sum(slots.values()) |
| if total < top_k: |
| |
| biggest = max(quotas, key=quotas.get) |
| slots[biggest] += top_k - total |
|
|
| |
| per_subject = {} |
| for p in predictions: |
| s = p["subject"] |
| if s not in per_subject: |
| per_subject[s] = [] |
| per_subject[s].append(p) |
|
|
| result = [] |
| for s, slot_count in slots.items(): |
| subj_preds = per_subject.get(s, []) |
| |
| result.extend(subj_preds[:slot_count]) |
|
|
| |
| result.sort(key=lambda x: x["final_score"], reverse=True) |
| return result[:top_k] |
|
|
|
|
| |
| |
| |
|
|
| def predict_chapters_v3(db_path="data/exam.db", target_year=2026, exam=None, top_k=50): |
| """ |
| Chapter-level 3-stage prediction. |
| |
| Returns list of dicts, one per unique chapter: |
| - chapter, subject |
| - appearance_probability |
| - expected_questions (count, min, max) |
| - likely_formats, likely_difficulty |
| - confidence, confidence_score |
| - final_score (combined) |
| - signal_breakdown |
| - top_micro_topic |
| - trend_direction |
| - syllabus_status |
| """ |
| full_df = get_questions_df(db_path) |
|
|
| |
| df = full_df[~full_df["year"].isin(HOLDOUT_YEARS)] |
| if exam: |
| df = df[df["exam"] == exam] |
| full_df = full_df[full_df["exam"] == exam] |
|
|
| if df.empty: |
| return [] |
|
|
| min_year = int(df["year"].min()) |
| max_year = int(df["year"].max()) |
|
|
| |
| cross_df = get_questions_df(db_path) |
| cross_df = cross_df[~cross_df["year"].isin(HOLDOUT_YEARS)] |
|
|
| |
| chapter_groups = df.groupby(["subject", "topic"]) |
|
|
| predictions = [] |
|
|
| for (subject, chapter), group in chapter_groups: |
| normalized_chapter = _normalize_chapter(chapter) |
|
|
| |
| syl_status, syl_gate = _syllabus_status(chapter, exam) |
| if syl_gate == 0.0: |
| predictions.append({ |
| "chapter": chapter, "normalized_chapter": normalized_chapter, |
| "subject": subject, |
| "appearance_probability": 0.0, "expected_questions": 0, |
| "expected_qs_min": 0, "expected_qs_max": 0, |
| "likely_formats": [], "likely_difficulty": 0, |
| "format_dominance": 0, "confidence": "HIGH", |
| "confidence_score": 0.95, "final_score": 0.0, |
| "signal_breakdown": {}, "reasons": ["Removed from syllabus"], |
| "top_micro_topic": "", "trend_direction": "REMOVED", |
| "syllabus_status": "REMOVED", "total_appearances": 0, |
| "total_questions": 0, "last_appeared": 0, |
| "training_years": f"{min_year}-{max_year}", |
| }) |
| continue |
|
|
| years_appeared = sorted(group["year"].unique()) |
| total_appearances = len(years_appeared) |
|
|
| |
| qs_per_year = group.groupby("year").size().to_dict() |
| question_types = group["question_type"].value_counts().to_dict() |
| difficulty_list = group["difficulty"].dropna().tolist() |
| recent_diffs = [d for y, d in zip(group["year"], group["difficulty"]) if y >= max_year - 4] |
|
|
| |
| micro_counts = group["micro_topic"].value_counts() |
| top_micro = micro_counts.index[0] if len(micro_counts) > 0 else chapter |
|
|
| |
| app_prob, app_signals = _appearance_probability( |
| years_appeared, (min_year, max_year), target_year, max_year |
| ) |
| app_prob *= syl_gate |
|
|
| |
| exp_qs, exp_min, exp_max, wt_conf = _expected_questions( |
| qs_per_year, years_appeared, target_year, max_year |
| ) |
|
|
| |
| likely_types, likely_diff, format_dom = _predict_format( |
| question_types, difficulty_list, recent_diffs |
| ) |
|
|
| |
| micro_cross = cross_df[cross_df["topic"] == chapter] |
| exams_present = micro_cross["exam"].unique() |
| cross_score = min(len(exams_present) / 3, 1.0) |
| app_signals["cross_exam"] = cross_score |
|
|
| |
| slope = app_signals.get("trend_slope", 0.5) |
| if slope > 0.6: |
| trend_dir = "RISING" |
| elif slope < 0.4: |
| trend_dir = "DECLINING" |
| else: |
| trend_dir = "STABLE" |
| if syl_status == "NEW": |
| trend_dir = "NEW" |
|
|
| |
| conf_score, conf_label = _confidence_score( |
| app_prob, wt_conf, total_appearances, syl_status, |
| app_signals.get("recent_3yr", 0), slope |
| ) |
|
|
| |
| recent_qs = [v for y, v in qs_per_year.items() if y >= max_year - 2] |
| recent_yield = np.mean(recent_qs) if recent_qs else 0 |
| yield_bonus = min(recent_yield / 6.0, 1.0) |
|
|
| |
| |
| normalized_exp_qs = min(exp_qs / 8.0, 1.0) |
| final_score = ( |
| 0.40 * app_prob + |
| 0.30 * normalized_exp_qs + |
| 0.15 * yield_bonus + |
| 0.10 * cross_score + |
| 0.05 * syl_gate |
| ) |
|
|
| |
| reasons = [] |
| if app_prob > 0.6: |
| reasons.append(f"High appearance probability ({app_prob:.0%}) — appeared {total_appearances} times") |
| if exp_qs >= 3: |
| reasons.append(f"Heavy chapter: expected ~{exp_qs:.0f} questions (range {exp_min}-{exp_max})") |
| if trend_dir == "RISING": |
| reasons.append("Rising trend — increasing frequency recently") |
| elif trend_dir == "DECLINING": |
| reasons.append("Declining trend — less frequent recently") |
| if app_signals.get("gap_return", 0) > 0.5: |
| gap = target_year - max(years_appeared) |
| reasons.append(f"Gap return signal: not seen in {gap} years") |
| if app_signals.get("cycle_match", 0) > 0.5: |
| reasons.append("Cycle match — periodic reappearance pattern") |
| if cross_score > 0.5: |
| reasons.append(f"Cross-exam presence: {', '.join(exams_present)}") |
| if syl_status == "NEW": |
| reasons.append("Newly added to syllabus — estimated through proxies") |
| if not reasons: |
| reasons.append("Low signal — limited data") |
|
|
| |
| signal_breakdown = {} |
| for k, v in app_signals.items(): |
| signal_breakdown[k] = {"value": round(v, 3)} |
| signal_breakdown["expected_qs"] = {"value": round(exp_qs, 1)} |
| signal_breakdown["weightage_confidence"] = {"value": round(wt_conf, 2)} |
|
|
| predictions.append({ |
| "chapter": chapter, |
| "normalized_chapter": normalized_chapter, |
| "subject": subject, |
| "appearance_probability": round(app_prob, 3), |
| "expected_questions": round(exp_qs, 1), |
| "expected_qs_min": exp_min, |
| "expected_qs_max": exp_max, |
| "likely_formats": likely_types, |
| "likely_difficulty": likely_diff, |
| "format_dominance": format_dom, |
| "confidence": conf_label, |
| "confidence_score": conf_score, |
| "final_score": round(final_score, 4), |
| "signal_breakdown": signal_breakdown, |
| "reasons": reasons, |
| "top_micro_topic": top_micro, |
| "trend_direction": trend_dir, |
| "syllabus_status": syl_status, |
| "total_appearances": total_appearances, |
| "total_questions": len(group), |
| "last_appeared": int(max(years_appeared)), |
| "training_years": f"{min_year}-{max_year}", |
| }) |
|
|
| |
| predictions.sort(key=lambda x: x["final_score"], reverse=True) |
|
|
| |
| seen = set() |
| deduped = [] |
| for p in predictions: |
| norm = p["normalized_chapter"].lower() |
| if norm not in seen: |
| seen.add(norm) |
| deduped.append(p) |
|
|
| |
| active = [p for p in deduped if p["syllabus_status"] != "REMOVED"] |
| removed = [p for p in deduped if p["syllabus_status"] == "REMOVED"] |
|
|
| if exam: |
| reranked = _subject_balanced_rerank(active, exam, top_k=top_k) |
| else: |
| reranked = active[:top_k] |
|
|
| return reranked + removed |
|
|
|
|
| |
| |
| |
|
|
| def predict_microtopics_v3(db_path="data/exam.db", target_year=2026, exam=None, top_k=100): |
| """ |
| Micro-topic-level 3-stage prediction. |
| |
| Like predict_chapters_v3 but grouped at (subject, topic, micro_topic) level. |
| Returns list of dicts with: micro_topic, chapter (parent), subject, |
| appearance_probability, expected_questions, expected_qs_min/max, |
| likely_formats, likely_difficulty, confidence, confidence_score, |
| final_score, signal_breakdown, reasons, trend_direction, syllabus_status. |
| """ |
| full_df = get_questions_df(db_path) |
|
|
| df = full_df[~full_df["year"].isin(HOLDOUT_YEARS)] |
| if exam: |
| df = df[df["exam"] == exam] |
| full_df = full_df[full_df["exam"] == exam] |
|
|
| if df.empty: |
| return [] |
|
|
| min_year = int(df["year"].min()) |
| max_year = int(df["year"].max()) |
|
|
| cross_df = get_questions_df(db_path) |
| cross_df = cross_df[~cross_df["year"].isin(HOLDOUT_YEARS)] |
|
|
| |
| micro_groups = df.groupby(["subject", "topic", "micro_topic"]) |
|
|
| predictions = [] |
|
|
| for (subject, chapter, micro_topic), group in micro_groups: |
| normalized_chapter = _normalize_chapter(chapter) |
|
|
| |
| syl_status, syl_gate = _syllabus_status(chapter, exam) |
| if syl_gate == 0.0: |
| predictions.append({ |
| "micro_topic": micro_topic, "chapter": chapter, |
| "normalized_chapter": normalized_chapter, "subject": subject, |
| "appearance_probability": 0.0, "expected_questions": 0, |
| "expected_qs_min": 0, "expected_qs_max": 0, |
| "likely_formats": [], "likely_difficulty": 0, |
| "format_dominance": 0, "confidence": "HIGH", |
| "confidence_score": 0.95, "final_score": 0.0, |
| "signal_breakdown": {}, "reasons": ["Removed from syllabus"], |
| "trend_direction": "REMOVED", "syllabus_status": "REMOVED", |
| "total_appearances": 0, "total_questions": 0, |
| "last_appeared": 0, "training_years": f"{min_year}-{max_year}", |
| }) |
| continue |
|
|
| years_appeared = sorted(group["year"].unique()) |
| total_appearances = len(years_appeared) |
| qs_per_year = group.groupby("year").size().to_dict() |
| question_types = group["question_type"].value_counts().to_dict() |
| difficulty_list = group["difficulty"].dropna().tolist() |
| recent_diffs = [d for y, d in zip(group["year"], group["difficulty"]) if y >= max_year - 4] |
|
|
| |
| app_prob, app_signals = _appearance_probability( |
| years_appeared, (min_year, max_year), target_year, max_year |
| ) |
| app_prob *= syl_gate |
|
|
| |
| exp_qs, exp_min, exp_max, wt_conf = _expected_questions( |
| qs_per_year, years_appeared, target_year, max_year |
| ) |
|
|
| |
| likely_types, likely_diff, format_dom = _predict_format( |
| question_types, difficulty_list, recent_diffs |
| ) |
|
|
| |
| ch_cross = cross_df[cross_df["topic"] == chapter] |
| exams_present = ch_cross["exam"].unique() |
| cross_score = min(len(exams_present) / 3, 1.0) |
| app_signals["cross_exam"] = cross_score |
|
|
| |
| slope = app_signals.get("trend_slope", 0.5) |
| if slope > 0.6: |
| trend_dir = "RISING" |
| elif slope < 0.4: |
| trend_dir = "DECLINING" |
| else: |
| trend_dir = "STABLE" |
| if syl_status == "NEW": |
| trend_dir = "NEW" |
|
|
| |
| conf_score, conf_label = _confidence_score( |
| app_prob, wt_conf, total_appearances, syl_status, |
| app_signals.get("recent_3yr", 0), slope |
| ) |
|
|
| |
| recent_qs_mt = [v for y, v in qs_per_year.items() if y >= max_year - 2] |
| recent_yield_mt = np.mean(recent_qs_mt) if recent_qs_mt else 0 |
| yield_bonus_mt = min(recent_yield_mt / 3.0, 1.0) |
|
|
| |
| normalized_exp_qs = min(exp_qs / 4.0, 1.0) |
| final_score = ( |
| 0.40 * app_prob + |
| 0.30 * normalized_exp_qs + |
| 0.15 * yield_bonus_mt + |
| 0.10 * cross_score + |
| 0.05 * syl_gate |
| ) |
|
|
| |
| reasons = [] |
| if app_prob > 0.6: |
| reasons.append(f"High appearance probability ({app_prob:.0%}) — appeared {total_appearances} times") |
| if exp_qs >= 2: |
| reasons.append(f"Recurring micro-topic: expected ~{exp_qs:.0f} questions (range {exp_min}-{exp_max})") |
| if trend_dir == "RISING": |
| reasons.append("Rising trend — increasing frequency recently") |
| elif trend_dir == "DECLINING": |
| reasons.append("Declining trend — less frequent recently") |
| if app_signals.get("gap_return", 0) > 0.5: |
| gap = target_year - max(years_appeared) |
| reasons.append(f"Gap return signal: not seen in {gap} years") |
| if app_signals.get("cycle_match", 0) > 0.5: |
| reasons.append("Cycle match — periodic reappearance pattern") |
| if cross_score > 0.5: |
| reasons.append(f"Cross-exam presence in: {', '.join(exams_present)}") |
| if syl_status == "NEW": |
| reasons.append("Parent chapter newly added — estimated via proxies") |
| if not reasons: |
| reasons.append("Low signal — limited data for this micro-topic") |
|
|
| signal_breakdown = {} |
| for k, v in app_signals.items(): |
| signal_breakdown[k] = {"value": round(v, 3)} |
| signal_breakdown["expected_qs"] = {"value": round(exp_qs, 1)} |
| signal_breakdown["weightage_confidence"] = {"value": round(wt_conf, 2)} |
|
|
| predictions.append({ |
| "micro_topic": micro_topic, |
| "chapter": chapter, |
| "normalized_chapter": normalized_chapter, |
| "subject": subject, |
| "appearance_probability": round(app_prob, 3), |
| "expected_questions": round(exp_qs, 1), |
| "expected_qs_min": exp_min, |
| "expected_qs_max": exp_max, |
| "likely_formats": likely_types, |
| "likely_difficulty": likely_diff, |
| "format_dominance": format_dom, |
| "confidence": conf_label, |
| "confidence_score": conf_score, |
| "final_score": round(final_score, 4), |
| "signal_breakdown": signal_breakdown, |
| "reasons": reasons, |
| "trend_direction": trend_dir, |
| "syllabus_status": syl_status, |
| "total_appearances": total_appearances, |
| "total_questions": len(group), |
| "last_appeared": int(max(years_appeared)), |
| "training_years": f"{min_year}-{max_year}", |
| }) |
|
|
| |
| predictions.sort(key=lambda x: x["final_score"], reverse=True) |
|
|
| |
| active = [p for p in predictions if p["syllabus_status"] != "REMOVED"] |
| removed = [p for p in predictions if p["syllabus_status"] == "REMOVED"] |
|
|
| if exam: |
| reranked = _subject_balanced_rerank(active, exam, top_k=top_k) |
| else: |
| reranked = active[:top_k] |
|
|
| return reranked + removed |
|
|
|
|
| |
| |
| |
|
|
| def backtest_v3(db_path="data/exam.db", test_years=None, exam=None, k=50): |
| """ |
| Backtest with new metrics: |
| - precision@K (chapters) |
| - coverage@K (fraction of actual paper questions covered) |
| - heavy_topic_recall (chapters with 3+ questions) |
| - subject_coverage (per-subject coverage balance) |
| - unique_chapters (how many unique chapters in top-K) |
| - rank_correlation (predicted importance vs actual) |
| """ |
| if test_years is None: |
| test_years = [2019, 2020, 2021, 2022, 2023] |
|
|
| full_df = get_questions_df(db_path) |
| if exam: |
| full_df = full_df[full_df["exam"] == exam] |
|
|
| results = [] |
|
|
| for test_year in test_years: |
| actual = full_df[full_df["year"] == test_year] |
| if actual.empty: |
| continue |
|
|
| actual_chapters = set(actual["topic"].unique()) |
| actual_qs_per_chapter = actual.groupby("topic").size().to_dict() |
| actual_total_qs = len(actual) |
|
|
| |
| heavy_chapters = {ch for ch, count in actual_qs_per_chapter.items() if count >= 3} |
|
|
| |
| actual_subj_qs = actual.groupby("subject").size().to_dict() |
| actual_total = sum(actual_subj_qs.values()) |
|
|
| |
| import analysis.predictor_v3 as self_mod |
| orig = self_mod.HOLDOUT_YEARS |
| self_mod.HOLDOUT_YEARS = {y for y in range(test_year, 2030)} |
|
|
| preds = predict_chapters_v3(db_path, target_year=test_year, exam=exam, top_k=k) |
|
|
| self_mod.HOLDOUT_YEARS = orig |
|
|
| if not preds: |
| continue |
|
|
| pred_chapters = [p["chapter"] for p in preds if p["syllabus_status"] != "REMOVED"][:k] |
| pred_set = set(pred_chapters) |
|
|
| |
|
|
| |
| hits = pred_set & actual_chapters |
| precision = len(hits) / k if k > 0 else 0 |
|
|
| |
| covered_qs = sum(actual_qs_per_chapter.get(ch, 0) for ch in pred_set) |
| coverage = covered_qs / actual_total_qs if actual_total_qs > 0 else 0 |
|
|
| |
| heavy_hits = pred_set & heavy_chapters |
| heavy_recall = len(heavy_hits) / len(heavy_chapters) if heavy_chapters else 0 |
|
|
| |
| pred_subj_qs = {} |
| for ch in pred_set: |
| ch_qs = actual_qs_per_chapter.get(ch, 0) |
| ch_subj = actual[actual["topic"] == ch]["subject"].mode() |
| if len(ch_subj) > 0: |
| s = ch_subj.iloc[0] |
| pred_subj_qs[s] = pred_subj_qs.get(s, 0) + ch_qs |
|
|
| subj_coverage = {} |
| for s, qs in actual_subj_qs.items(): |
| covered = pred_subj_qs.get(s, 0) |
| subj_coverage[s] = round(covered / qs, 3) if qs > 0 else 0 |
|
|
| |
| avg_subj_cov = np.mean(list(subj_coverage.values())) if subj_coverage else 0 |
|
|
| |
| unique_count = len(pred_set) |
|
|
| |
| from scipy.stats import spearmanr |
| pred_ranks = {ch: i for i, ch in enumerate(pred_chapters)} |
| common = pred_set & actual_chapters |
| if len(common) >= 5: |
| pred_r = [pred_ranks[ch] for ch in common] |
| actual_r = [actual_qs_per_chapter[ch] for ch in common] |
| rank_corr, _ = spearmanr(pred_r, [-x for x in actual_r]) |
| else: |
| rank_corr = 0 |
|
|
| |
| combined = (0.35 * precision + 0.40 * coverage + |
| 0.15 * heavy_recall + 0.10 * avg_subj_cov) |
|
|
| results.append({ |
| "test_year": test_year, |
| "actual_chapters": len(actual_chapters), |
| "actual_questions": actual_total_qs, |
| "precision_at_k": round(precision, 3), |
| "coverage_at_k": round(coverage, 3), |
| "heavy_topic_recall": round(heavy_recall, 3), |
| "subject_coverage": subj_coverage, |
| "avg_subject_coverage": round(avg_subj_cov, 3), |
| "unique_chapters": unique_count, |
| "rank_correlation": round(rank_corr, 3), |
| "combined_score": round(combined, 3), |
| "k": k, |
| "questions_covered": covered_qs, |
| "heavy_topics_hit": len(heavy_hits), |
| "heavy_topics_total": len(heavy_chapters), |
| }) |
|
|
| return results |
|
|
|
|
| |
| |
| |
|
|
| def backtest_single_year(db_path="data/exam.db", test_year=2020, exam=None, |
| k=50, level="chapter"): |
| """ |
| Train on data strictly before test_year, predict for test_year, |
| compare predictions against actual paper. |
| |
| level: "chapter" or "micro" |
| |
| Returns (summary_dict, actual_df) where summary_dict has: |
| - precision_at_k, coverage_at_k, heavy_topic_recall |
| - subject_coverage per subject |
| - combined_score (0.35P + 0.40C + 0.15H + 0.10S) |
| - hit_topics, missed_topics, false_positives |
| - per-topic breakdown with predicted_rank, actual_qs |
| """ |
| import analysis.predictor_v3 as self_mod |
| from scipy.stats import spearmanr |
|
|
| full_df = get_questions_df(db_path) |
| if exam: |
| full_df = full_df[full_df["exam"] == exam] |
|
|
| actual = full_df[full_df["year"] == test_year] |
| if actual.empty: |
| return None, None |
|
|
| |
| orig_holdout = self_mod.HOLDOUT_YEARS |
| self_mod.HOLDOUT_YEARS = set(range(test_year, 2030)) |
|
|
| try: |
| if level == "micro": |
| preds = predict_microtopics_v3(db_path, target_year=test_year, exam=exam, top_k=k) |
| else: |
| preds = predict_chapters_v3(db_path, target_year=test_year, exam=exam, top_k=k) |
| finally: |
| self_mod.HOLDOUT_YEARS = orig_holdout |
|
|
| if not preds: |
| return None, actual |
|
|
| active_preds = [p for p in preds if p["syllabus_status"] != "REMOVED"][:k] |
|
|
| |
| if level == "micro": |
| actual_key = "micro_topic" |
| pred_key = "micro_topic" |
| else: |
| actual_key = "topic" |
| pred_key = "chapter" |
|
|
| actual_set = set(actual[actual_key].unique()) |
| actual_qs_map = actual.groupby(actual_key).size().to_dict() |
| actual_subj_qs = actual.groupby("subject").size().to_dict() |
| actual_total = len(actual) |
|
|
| pred_list_names = [p[pred_key] for p in active_preds] |
| pred_set = set(pred_list_names) |
| pred_ranks = {name: i + 1 for i, name in enumerate(pred_list_names)} |
|
|
| |
| hits = pred_set & actual_set |
| missed = actual_set - pred_set |
| false_pos = pred_set - actual_set |
|
|
| precision = len(hits) / k if k > 0 else 0 |
|
|
| covered_qs = sum(actual_qs_map.get(t, 0) for t in pred_set) |
| coverage = covered_qs / actual_total if actual_total > 0 else 0 |
|
|
| heavy_actual = {t for t, c in actual_qs_map.items() if c >= 3} |
| heavy_hits = pred_set & heavy_actual |
| heavy_recall = len(heavy_hits) / len(heavy_actual) if heavy_actual else 0 |
|
|
| |
| subj_covered_qs = {} |
| for t in pred_set: |
| qs = actual_qs_map.get(t, 0) |
| subj = actual[actual[actual_key] == t]["subject"].mode() |
| if len(subj) > 0: |
| s = subj.iloc[0] |
| subj_covered_qs[s] = subj_covered_qs.get(s, 0) + qs |
|
|
| subj_coverage = {} |
| for s, qs in actual_subj_qs.items(): |
| subj_coverage[s] = round(subj_covered_qs.get(s, 0) / qs, 3) if qs > 0 else 0 |
|
|
| avg_subj_cov = np.mean(list(subj_coverage.values())) if subj_coverage else 0 |
|
|
| combined = 0.35 * precision + 0.40 * coverage + 0.15 * heavy_recall + 0.10 * avg_subj_cov |
|
|
| |
| common = hits |
| rank_corr = 0.0 |
| if len(common) >= 5: |
| pred_r = [pred_ranks[t] for t in common] |
| actual_r = [actual_qs_map[t] for t in common] |
| rank_corr, _ = spearmanr(pred_r, [-x for x in actual_r]) |
|
|
| |
| topic_breakdown = [] |
| for t in sorted(actual_set, key=lambda x: -actual_qs_map.get(x, 0)): |
| status = "HIT" if t in pred_set else "MISSED" |
| rank = pred_ranks.get(t, None) |
| |
| t_subj = actual[actual[actual_key] == t]["subject"].mode() |
| t_subj = t_subj.iloc[0] if len(t_subj) > 0 else "Unknown" |
| topic_breakdown.append({ |
| "topic": t, |
| "subject": t_subj, |
| "actual_qs": actual_qs_map.get(t, 0), |
| "status": status, |
| "predicted_rank": rank, |
| "is_heavy": t in heavy_actual, |
| }) |
|
|
| |
| fp_breakdown = [] |
| for p in active_preds: |
| name = p[pred_key] |
| if name not in actual_set: |
| fp_breakdown.append({ |
| "topic": name, |
| "subject": p["subject"], |
| "predicted_rank": pred_ranks.get(name, 0), |
| "appearance_prob": p["appearance_probability"], |
| "confidence": p["confidence"], |
| }) |
|
|
| summary = { |
| "test_year": test_year, |
| "exam": exam or "All", |
| "level": level, |
| "k": k, |
| "precision_at_k": round(precision, 3), |
| "coverage_at_k": round(coverage, 3), |
| "heavy_topic_recall": round(heavy_recall, 3), |
| "avg_subject_coverage": round(avg_subj_cov, 3), |
| "rank_correlation": round(rank_corr, 3), |
| "combined_score": round(combined, 3), |
| "hits": len(hits), |
| "misses": len(missed), |
| "false_positives": len(false_pos), |
| "actual_topics": len(actual_set), |
| "actual_questions": actual_total, |
| "questions_covered": covered_qs, |
| "heavy_topics_hit": len(heavy_hits), |
| "heavy_topics_total": len(heavy_actual), |
| "subject_coverage": subj_coverage, |
| "topic_breakdown": topic_breakdown, |
| "fp_breakdown": fp_breakdown, |
| } |
|
|
| return summary, actual |
|
|