Skip to content

Commit bd75bd8

Browse files
feat: data-driven admission probability + projected profile mode
## Core changes ### P0: LR-driven Reach/Target/Safety classification - Add `core/lr_predictor.py`: loads trained logistic regression models and provides `predict_prob(program_id, gpa, gre)` → P(admission) - Modify `core/school_ranker.py`: classification now uses LR probability thresholds (≥70% Safety / 40-70% Target / <40% Reach) instead of heuristic GPA rules; falls back to heuristics when no model exists - `quantpath list` already displayed P(Admit); classification now matches ### P1: Projected evaluation mode - Add `planned_coursework` field to `UserProfile` (core/models.py) - Parse `planned_courses:` YAML block in `core/data_loader.py` - `evaluate(projected=True)` in `core/profile_evaluator.py` merges planned courses (assumed grade A) for applicant-time profile scoring - CLI: `--projected` flag on `evaluate` and `list` commands; `evaluate` shows a Current vs Projected comparison table ## Data pipeline - `tools/collect_data.py`, `tools/parse_admissions.py`: multi-source admission data collectors (QuantNet / GradCafe / Chinese forums) - `tools/scrape_gradcafe.py`: pure-rules GradCafe scraper (no API needed) - `tools/clean_data.py`: dedup + pollution fix (9,724 → 6,984 records) - `tools/train_model.py`: per-program logistic regression on GPA+GRE; trains 21 programs, saves to `data/models/admission_models.json` - `data/admissions/collected.csv`: 6,984 cleaned admission records (QuantNet 6,723 + GradCafe 234 + manual 27), seasons 2008–2026 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 02b1bd6 commit bd75bd8

15 files changed

Lines changed: 9711 additions & 60 deletions

cli/main.py

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
load_questions,
2222
)
2323
from core.list_builder import build_school_list
24+
from core.lr_predictor import predict_prob, get_model_stats
2425
from core.prerequisite_matcher import match_prerequisites
2526
from core.profile_evaluator import evaluate as evaluate_profile
2627
from core.roi_calculator import calculate_roi
@@ -41,12 +42,13 @@ def cmd_evaluate(args: argparse.Namespace) -> None:
4142
"""Evaluate a user profile against MFE programs."""
4243
profile = load_profile(args.profile)
4344
programs = load_all_programs()
44-
result = evaluate_profile(profile)
45+
projected = getattr(args, "projected", False)
46+
result = evaluate_profile(profile, projected=projected)
4547

4648
# PDF output path requested
4749
output_path = getattr(args, "output", None)
4850
if output_path and output_path.endswith(".pdf"):
49-
rankings = rank_schools(profile, programs, result)
51+
rankings = rank_schools(profile, programs, result, projected=projected)
5052
gap_recs = analyze_gaps(result.gaps) if result.gaps else []
5153

5254
from core.report_generator import generate_report
@@ -61,12 +63,14 @@ def cmd_evaluate(args: argparse.Namespace) -> None:
6163
console.print(f"[green]PDF report saved to:[/green] {path}")
6264
return
6365

66+
mode_label = " [bold yellow](Projected — including planned courses)[/bold yellow]" if projected else ""
6467
# Header
6568
console.print()
6669
console.print(
6770
Panel(
6871
f"[bold]{profile.name}[/bold] | {profile.university} | "
69-
f"GPA {profile.gpa} | {'International' if profile.is_international else 'Domestic'}",
72+
f"GPA {profile.gpa} | {'International' if profile.is_international else 'Domestic'}"
73+
+ mode_label,
7074
title="QuantPath Profile Evaluation",
7175
border_style="cyan",
7276
)
@@ -116,20 +120,71 @@ def cmd_evaluate(args: argparse.Namespace) -> None:
116120
)
117121
console.print()
118122

119-
# School recommendations
120-
rankings = rank_schools(profile, programs, result)
123+
# School recommendations (use projected mode if flag set)
124+
rankings = rank_schools(profile, programs, result, projected=projected)
121125

122126
if rankings.get("reach"):
123127
reach_names = ", ".join(r["name"] for r in rankings["reach"])
124-
console.print(f" 🎯 [bold]Reach:[/bold] {reach_names}")
128+
console.print(f" Reach: {reach_names}")
125129
if rankings.get("target"):
126130
target_names = ", ".join(r["name"] for r in rankings["target"])
127-
console.print(f" 🎯 [bold]Target:[/bold] {target_names}")
131+
console.print(f" Target: {target_names}")
128132
if rankings.get("safety"):
129133
safety_names = ", ".join(r["name"] for r in rankings["safety"])
130-
console.print(f" 🎯 [bold]Safety:[/bold] {safety_names}")
134+
console.print(f" Safety: {safety_names}")
131135
console.print()
132136

137+
# If projected mode, show comparison: current vs projected
138+
if projected and profile.planned_coursework:
139+
current_result = evaluate_profile(profile, projected=False)
140+
curr_overall = current_result.overall_score
141+
proj_overall = result.overall_score
142+
diff = proj_overall - curr_overall
143+
diff_color = "green" if diff > 0 else "red"
144+
145+
compare_table = Table(
146+
title="[bold]Current vs Projected Profile[/bold]",
147+
border_style="yellow",
148+
show_lines=True,
149+
)
150+
compare_table.add_column("Dimension", style="bold", width=16)
151+
compare_table.add_column("Current", justify="right", width=10)
152+
compare_table.add_column("Projected", justify="right", width=10)
153+
compare_table.add_column("Change", justify="right", width=10)
154+
155+
dim_labels_compare = {
156+
"math": "Math",
157+
"statistics": "Statistics",
158+
"cs": "CS",
159+
"finance_econ": "Finance/Econ",
160+
"gpa": "GPA",
161+
}
162+
for dim_id, label in dim_labels_compare.items():
163+
curr_s = current_result.dimension_scores.get(dim_id, 0)
164+
proj_s = result.dimension_scores.get(dim_id, 0)
165+
delta = proj_s - curr_s
166+
d_color = "green" if delta > 0.1 else "dim" if abs(delta) <= 0.1 else "red"
167+
compare_table.add_row(
168+
label,
169+
f"{curr_s:.1f}",
170+
f"[bold]{proj_s:.1f}[/bold]",
171+
f"[{d_color}]{delta:+.1f}[/{d_color}]",
172+
)
173+
compare_table.add_row(
174+
"[bold]OVERALL[/bold]",
175+
f"{curr_overall:.1f}",
176+
f"[bold]{proj_overall:.1f}[/bold]",
177+
f"[{diff_color}]{diff:+.1f}[/{diff_color}]",
178+
)
179+
console.print(compare_table)
180+
n_planned = len(profile.planned_coursework)
181+
console.print(
182+
f" [dim]({n_planned} planned courses included: "
183+
+ ", ".join(c.code for c in profile.planned_coursework[:5])
184+
+ ("..." if n_planned > 5 else "") + ")[/dim]"
185+
)
186+
console.print()
187+
133188
# Gaps
134189
if result.gaps:
135190
console.print(" [bold red]⚠️ Gaps Found:[/bold red]")
@@ -563,9 +618,14 @@ def cmd_list(args: argparse.Namespace) -> None:
563618
"""Build and display an optimised school application list."""
564619
profile = load_profile(args.profile)
565620
programs = load_all_programs()
566-
evaluation = evaluate_profile(profile)
621+
projected = getattr(args, "projected", False)
622+
evaluation = evaluate_profile(profile, projected=projected)
567623
school_list = build_school_list(profile, programs, evaluation)
568624

625+
# Determine GRE Quant score for LR prediction
626+
gre_quant = profile.test_scores.gre_quant
627+
gpa = profile.gpa
628+
569629
console.print()
570630
console.print(
571631
Panel(
@@ -591,14 +651,22 @@ def cmd_list(args: argparse.Namespace) -> None:
591651
table.add_column("University", min_width=18)
592652
table.add_column("Fit", justify="right", width=6)
593653
table.add_column("Prereq", justify="right", width=7)
594-
table.add_column("Reason", min_width=30)
654+
table.add_column("P(Admit)", justify="right", width=9)
655+
table.add_column("Reason", min_width=28)
595656

596657
for e in entries:
658+
prob = predict_prob(e.program_id, gpa, gre_quant)
659+
if prob is not None:
660+
pcolor = "green" if prob >= 0.6 else "yellow" if prob >= 0.35 else "red"
661+
prob_str = f"[{pcolor}]{prob:.0%}[/{pcolor}]"
662+
else:
663+
prob_str = "[dim]N/A[/dim]"
597664
table.add_row(
598665
e.name,
599666
e.university,
600667
f"{e.fit_score:.1f}",
601668
f"{e.prereq_match_score:.0%}",
669+
prob_str,
602670
e.reason,
603671
)
604672
console.print(table)
@@ -1018,6 +1086,11 @@ def main() -> None:
10181086
"-o",
10191087
help="Output file path (use .pdf extension for PDF report)",
10201088
)
1089+
p_eval.add_argument(
1090+
"--projected",
1091+
action="store_true",
1092+
help="Include planned_courses from profile YAML (shows profile at application time)",
1093+
)
10211094

10221095
# match
10231096
p_match = subparsers.add_parser("match", help="Match prerequisites")
@@ -1114,6 +1187,11 @@ def main() -> None:
11141187
# list
11151188
p_list = subparsers.add_parser("list", help="Build optimised school application list")
11161189
p_list.add_argument("--profile", "-p", required=True, help="Path to profile YAML")
1190+
p_list.add_argument(
1191+
"--projected",
1192+
action="store_true",
1193+
help="Include planned courses (shows school list at application time)",
1194+
)
11171195

11181196
args = parser.parse_args()
11191197

core/data_loader.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,9 +234,13 @@ def load_profile(path: str) -> UserProfile:
234234
# Courses — support both "courses" and "coursework" keys
235235
raw_courses = raw.get("courses", raw.get("coursework", []))
236236

237+
# Planned future courses (for projected evaluation mode)
238+
raw_planned = raw.get("planned_courses", [])
239+
237240
return UserProfile(
238241
name=personal.get("name", raw.get("name", "")),
239242
coursework=[_parse_course(c) for c in raw_courses],
243+
planned_coursework=[_parse_course(c) for c in raw_planned],
240244
gpa=float(personal.get("gpa", raw.get("gpa", 0.0))),
241245
gpa_quant=float(raw.get("gpa_quant", 0.0)),
242246
university=personal.get("university", raw.get("university", "")),

core/lr_predictor.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Logistic regression admission probability predictor.
2+
3+
Loads pre-trained per-program models from data/models/admission_models.json
4+
and provides P(admission) predictions given GPA and GRE Quant.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import json
10+
import math
11+
from pathlib import Path
12+
from typing import Optional
13+
14+
_MODEL_PATH = Path(__file__).parent.parent / "data" / "models" / "admission_models.json"
15+
_models: dict | None = None
16+
17+
18+
def _load_models() -> dict:
19+
global _models
20+
if _models is None:
21+
if _MODEL_PATH.exists():
22+
with _MODEL_PATH.open(encoding="utf-8") as f:
23+
_models = json.load(f)
24+
else:
25+
_models = {}
26+
return _models
27+
28+
29+
def predict_prob(
30+
program_id: str,
31+
gpa: Optional[float],
32+
gre: Optional[float],
33+
) -> Optional[float]:
34+
"""Return P(admission) for a given program, GPA (4-scale), and GRE Quant.
35+
36+
Returns None if the program has no trained model or inputs are missing.
37+
"""
38+
models = _load_models()
39+
m = models.get(program_id)
40+
if not m:
41+
return None
42+
if gpa is None and gre is None:
43+
return None
44+
45+
means = m["means"]
46+
stds = m["stds"]
47+
coef = m["coef"]
48+
intercept = m["intercept"]
49+
50+
# Use model mean as fallback for missing values
51+
gpa_val = gpa if gpa is not None else means[0]
52+
gre_val = gre if gre is not None else means[1]
53+
54+
z_gpa = (gpa_val - means[0]) / stds[0]
55+
z_gre = (gre_val - means[1]) / stds[1]
56+
57+
logit = coef[0] * z_gpa + coef[1] * z_gre + intercept
58+
prob = 1.0 / (1.0 + math.exp(-logit))
59+
return round(prob, 4)
60+
61+
62+
def get_model_stats(program_id: str) -> Optional[dict]:
63+
"""Return model stats (n, accept_rate, AUC, GPA/GRE percentiles) for a program."""
64+
return _load_models().get(program_id)
65+
66+
67+
def has_model(program_id: str) -> bool:
68+
return program_id in _load_models()

core/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ class UserProfile:
116116
# Tests
117117
test_scores: TestScores = field(default_factory=TestScores)
118118

119+
# Planned future courses (for projected evaluation mode)
120+
planned_coursework: list[Course] = field(default_factory=list)
121+
119122
# Experience
120123
work_experience: list[dict[str, Any]] = field(default_factory=list)
121124
projects: list[dict[str, Any]] = field(default_factory=list)

core/profile_evaluator.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -461,20 +461,17 @@ def _weighted_result(
461461
_STRENGTH_THRESHOLD = 9.0 # factors at or above this are strengths
462462

463463

464-
def evaluate(profile: UserProfile) -> EvaluationResult:
464+
def evaluate(profile: UserProfile, projected: bool = False) -> EvaluationResult:
465465
"""Run the full 5-dimension evaluation on *profile*.
466466
467-
Steps:
468-
1. Score every factor within each dimension.
469-
2. Compute each dimension's weighted score.
470-
3. Compute the overall score (weighted across dimensions).
471-
4. Identify gaps (score 0 or below 6).
472-
5. Identify strengths (score >= 9).
473-
474467
Parameters
475468
----------
476469
profile:
477470
A fully-populated :class:`UserProfile`.
471+
projected:
472+
If True, merge ``profile.planned_coursework`` into the evaluation,
473+
assuming all planned courses will be completed with an A grade.
474+
Use this to see what the profile will look like at application time.
478475
479476
Returns
480477
-------
@@ -483,6 +480,19 @@ def evaluate(profile: UserProfile) -> EvaluationResult:
483480
``strengths``, and an empty ``school_recommendations`` dict
484481
(filled later by the school ranker).
485482
"""
483+
if projected and profile.planned_coursework:
484+
# Merge planned courses in with grade "A" (assumed successful completion)
485+
import copy
486+
profile = copy.copy(profile)
487+
extra = []
488+
for c in profile.planned_coursework:
489+
pc = copy.copy(c)
490+
if not pc.grade or pc.grade in ("TBD", ""):
491+
pc.grade = "A"
492+
extra.append(pc)
493+
profile = copy.copy(profile)
494+
profile.coursework = list(profile.coursework) + extra
495+
486496
scorers: dict[str, Any] = {
487497
"math": _score_math,
488498
"statistics": _score_statistics,

0 commit comments

Comments
 (0)