Rohan Kumar commited on
Commit
71e6ce1
·
1 Parent(s): b9a91bc

Deploy DeBERTa MCQ solver with Gradio

Browse files
README.md CHANGED
@@ -1,14 +1,23 @@
1
  ---
2
- title: Smart Mcq Solver
3
- emoji: 🏃
4
- colorFrom: red
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.22.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
- license: mit
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Smart MCQ Solver (DeBERTa)
3
+ emoji: 🧠
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: gradio
7
+ sdk_version: "4.44.0"
 
8
  app_file: app.py
9
  pinned: false
 
10
  ---
11
 
12
+ # 🧠 Smart MCQ Solver DeBERTa-v3-small
13
+
14
+ Answers 5-option multiple-choice questions using a fine-tuned DeBERTa-v3-small
15
+ model — the best individual model from the Smart MCQ Solver project
16
+ (0.7544 MAP@3 on the Kaggle leaderboard).
17
+
18
+ ## How it works
19
+
20
+ Each (question, option) pair is scored independently as "correct" vs "wrong"
21
+ using a pretrained DeBERTa-v3-small backbone with a custom classification head
22
+ on the `[CLS]` token. The 5 option scores per question are ranked to produce
23
+ a top-3 answer.
app.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py
3
+ ======
4
+ Gradio app for HF Spaces — deploys the best single model (DeBERTa-v3-small,
5
+ 0.7544 MAP@3 on the Kaggle leaderboard) as a standalone MCQ solver.
6
+ """
7
+
8
+ import gradio as gr
9
+
10
+ from src.models.deberta import DeBERTaModel
11
+
12
+ _model = None
13
+
14
+
15
+ def get_model():
16
+ global _model
17
+ if _model is None:
18
+ _model = DeBERTaModel().load()
19
+ return _model
20
+
21
+
22
+ def predict(prompt, opt_a, opt_b, opt_c, opt_d, opt_e):
23
+ if not prompt or not prompt.strip():
24
+ return "⚠️ Please enter a question.", {}
25
+
26
+ options = [opt_a, opt_b, opt_c, opt_d, opt_e]
27
+ if any(not (o or "").strip() for o in options):
28
+ return "⚠️ Please fill in all 5 options (A-E).", {}
29
+
30
+ model = get_model()
31
+ proba = model.predict_proba_single(prompt, options)
32
+ top3 = model.predict_top3_single(prompt, options)
33
+
34
+ option_text = {"A": opt_a, "B": opt_b, "C": opt_c, "D": opt_d, "E": opt_e}
35
+ medal = ["🥇", "🥈", "🥉"]
36
+ answer_text = "\n".join(
37
+ f"{medal[i]} **{letter}**: {option_text[letter]}" for i, letter in enumerate(top3)
38
+ )
39
+
40
+ confidences = {letter: float(score) for letter, score in zip("ABCDE", proba)}
41
+ return answer_text, confidences
42
+
43
+
44
+ demo = gr.Interface(
45
+ fn=predict,
46
+ inputs=[
47
+ gr.Textbox(label="Question", lines=3,
48
+ placeholder="e.g. What is the powerhouse of the cell?"),
49
+ gr.Textbox(label="Option A"),
50
+ gr.Textbox(label="Option B"),
51
+ gr.Textbox(label="Option C"),
52
+ gr.Textbox(label="Option D"),
53
+ gr.Textbox(label="Option E"),
54
+ ],
55
+ outputs=[
56
+ gr.Markdown(label="Top-3 Answer"),
57
+ gr.Label(label="Confidence per option", num_top_classes=5),
58
+ ],
59
+ title="🧠 Smart MCQ Solver — DeBERTa-v3-small",
60
+ description=(
61
+ "Answers 5-option multiple-choice questions using a fine-tuned "
62
+ "**DeBERTa-v3-small** model — the best individual model in this "
63
+ "project (0.7544 MAP@3 on the Kaggle leaderboard)."
64
+ ),
65
+ )
66
+
67
+ if __name__ == "__main__":
68
+ demo.launch()
outputs/checkpoints/deberta_best.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:17c75824be1016c3b66f52108306f91efa99824fd2a9b10cb8a7ca36c6c9a609
3
+ size 565267989
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio==4.44.0
2
+ torch==2.3.1
3
+ transformers==4.40.0
4
+ sentencepiece==0.2.0
5
+ numpy==1.26.4
src/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Smart MCQ Solver — production src package."""
2
+
3
+ from .predict import predict
4
+
5
+ __all__ = ["predict"]
src/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (279 Bytes). View file
 
src/__pycache__/config.cpython-312.pyc ADDED
Binary file (2.66 kB). View file
 
src/__pycache__/ensemble.cpython-312.pyc ADDED
Binary file (3.39 kB). View file
 
src/__pycache__/inference.cpython-312.pyc ADDED
Binary file (2.76 kB). View file
 
src/__pycache__/metrics.cpython-312.pyc ADDED
Binary file (3.49 kB). View file
 
src/__pycache__/predict.cpython-312.pyc ADDED
Binary file (1.77 kB). View file
 
src/__pycache__/preprocessing.cpython-312.pyc ADDED
Binary file (8.31 kB). View file
 
src/__pycache__/utils.cpython-312.pyc ADDED
Binary file (4.62 kB). View file
 
src/config.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ config.py
3
+ =========
4
+ Single source of truth for every hyperparameter, path, and constant used
5
+ across the Smart MCQ Solver project. Every notebook (01_eda -> 06_ensemble)
6
+ used these exact values during training, so inference MUST use the same
7
+ ones or predictions will not match the trained checkpoints.
8
+ """
9
+
10
+ from pathlib import Path
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Paths
14
+ # ---------------------------------------------------------------------------
15
+ BASE_DIR = Path(__file__).resolve().parent.parent
16
+ DATA_DIR = BASE_DIR / "data"
17
+ OUTPUT_DIR = BASE_DIR / "outputs"
18
+ CHECKPOINT_DIR = OUTPUT_DIR / "checkpoints"
19
+ PREDICTIONS_DIR = OUTPUT_DIR / "predictions"
20
+ LOGS_DIR = OUTPUT_DIR / "logs"
21
+
22
+ for d in (CHECKPOINT_DIR, PREDICTIONS_DIR, LOGS_DIR):
23
+ d.mkdir(parents=True, exist_ok=True)
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Answer label maps (used identically in all 4 model notebooks)
27
+ # ---------------------------------------------------------------------------
28
+ ANSWER_MAP = {"A": 0, "B": 1, "C": 2, "D": 3, "E": 4}
29
+ REVERSE_MAP = {v: k for k, v in ANSWER_MAP.items()}
30
+ OPTION_COLS = list("ABCDE")
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Reproducibility
34
+ # ---------------------------------------------------------------------------
35
+ RANDOM_STATE = 42
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # 1. TF-IDF + Logistic Regression baseline (02_baseline.ipynb)
39
+ # Best strategy in the notebook was picked dynamically (max val MAP@3);
40
+ # all three text-builder strategies are implemented in preprocessing.py.
41
+ # Standalone leaderboard score: 0.751
42
+ # ---------------------------------------------------------------------------
43
+ TFIDF_CFG = {
44
+ "text_strategy": "v3_labeled", # change if a different variant was your best
45
+ "max_features": 15000,
46
+ "min_df": 2,
47
+ "max_df": 0.9,
48
+ "ngram_range": (1, 3),
49
+ "lr_C": 3.0,
50
+ "model_path": CHECKPOINT_DIR / "tfidf_best_lr_model.pkl",
51
+ "vectorizer_path": CHECKPOINT_DIR / "tfidf_best_vectorizer.pkl",
52
+ }
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # 2. LSTM from scratch (03_lstm.ipynb)
56
+ # Standalone leaderboard score: 0.7543
57
+ # ---------------------------------------------------------------------------
58
+ LSTM_CFG = {
59
+ "vocab_size": 10000,
60
+ "max_len": 256,
61
+ "embed_dim": 128,
62
+ "hidden_dim": 256,
63
+ "num_layers": 2,
64
+ "dropout": 0.4,
65
+ "num_classes": 5,
66
+ "checkpoint_path": CHECKPOINT_DIR / "lstm_best.pt",
67
+ "tokenizer_path": CHECKPOINT_DIR / "lstm_tokenizer.pkl",
68
+ }
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # 3. DeBERTa-v3-small option scorer (04_DeBERTa.ipynb)
72
+ # Standalone leaderboard score: 0.7547 (best single model)
73
+ # ---------------------------------------------------------------------------
74
+ DEBERTA_CFG = {
75
+ "model_name": "microsoft/deberta-v3-small",
76
+ "max_len": 256,
77
+ "dropout": 0.3,
78
+ "checkpoint_path": CHECKPOINT_DIR / "deberta_best.pt",
79
+ }
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # 4. RoBERTa multiple-choice (05_RoBERTa.ipynb)
83
+ # Standalone leaderboard score: 0.75436
84
+ # ---------------------------------------------------------------------------
85
+ ROBERTA_CFG = {
86
+ "model_name": "roberta-base",
87
+ "max_len": 128,
88
+ "checkpoint_path": CHECKPOINT_DIR / "roberta_best.pt",
89
+ }
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # 5. Ensemble weights (06_ensemble.ipynb) — weighted rank ensemble
93
+ # Tuned empirically against the leaderboard. Must sum to 1.0.
94
+ # Final ensemble leaderboard score: 0.76018 (best overall)
95
+ # ---------------------------------------------------------------------------
96
+ ENSEMBLE_WEIGHTS = {
97
+ "tfidf": 0.15,
98
+ "lstm": 0.20,
99
+ "deberta": 0.40,
100
+ "roberta": 0.25,
101
+ }
102
+
103
+ assert abs(sum(ENSEMBLE_WEIGHTS.values()) - 1.0) < 1e-9, "Ensemble weights must sum to 1.0!"
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Individual model standalone scores (for reference / README / app.py display)
107
+ # ---------------------------------------------------------------------------
108
+ LEADERBOARD_SCORES = {
109
+ "tfidf": 0.7510,
110
+ "lstm": 0.7543,
111
+ "roberta": 0.75436,
112
+ "deberta": 0.7547,
113
+ "ensemble": 0.76018,
114
+ }
115
+
116
+
117
+ # WANDB SETTINGS
118
+ WANDB_PROJECT = "23f2003236-t22026"
119
+ WANDB_ENTITY = "23f2003236"
120
+
121
+
src/dataset.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ dataset.py
3
+ ==========
4
+ PyTorch Dataset classes for batch training/evaluation (used if you ever
5
+ retrain a model, not needed for single-question inference in predict.py,
6
+ which calls the tokenizer directly for speed/simplicity).
7
+ """
8
+
9
+ from typing import List
10
+
11
+ import pandas as pd
12
+ import torch
13
+ from torch.utils.data import Dataset
14
+
15
+ from .config import ANSWER_MAP, OPTION_COLS
16
+ from .preprocessing import build_lstm_text
17
+
18
+
19
+ class MCQDataset(Dataset):
20
+ """LSTM dataset (03_lstm.ipynb) — one combined text per question."""
21
+
22
+ def __init__(self, df: pd.DataFrame, tokenizer, max_len: int, is_test: bool = False):
23
+ self.df = df.reset_index(drop=True)
24
+ self.tokenizer = tokenizer
25
+ self.max_len = max_len
26
+ self.is_test = is_test
27
+
28
+ def __len__(self):
29
+ return len(self.df)
30
+
31
+ def __getitem__(self, idx):
32
+ row = self.df.iloc[idx]
33
+ text = build_lstm_text(row["prompt"], [row[c] for c in OPTION_COLS])
34
+
35
+ ids = self.tokenizer.encode(text)
36
+ ids = self.tokenizer.pad_or_truncate(ids, self.max_len)
37
+ id_tensor = torch.tensor(ids, dtype=torch.long)
38
+
39
+ if self.is_test:
40
+ return id_tensor
41
+
42
+ label = ANSWER_MAP[row["answer"]]
43
+ return id_tensor, torch.tensor(label, dtype=torch.long)
44
+
45
+
46
+ class OptionDataset(Dataset):
47
+ """DeBERTa dataset (04_DeBERTa.ipynb) — expects a pre-expanded pairs_df
48
+ (see preprocessing.expand_pairs), one row per (question, option) pair."""
49
+
50
+ def __init__(self, pairs_df: pd.DataFrame, tokenizer, max_len: int, is_test: bool = False):
51
+ self.df = pairs_df.reset_index(drop=True)
52
+ self.tok = tokenizer
53
+ self.max_len = max_len
54
+ self.is_test = is_test
55
+
56
+ def __len__(self):
57
+ return len(self.df)
58
+
59
+ def __getitem__(self, idx):
60
+ row = self.df.iloc[idx]
61
+ enc = self.tok(
62
+ row["text"],
63
+ max_length=self.max_len,
64
+ truncation=True,
65
+ padding="max_length",
66
+ return_tensors="pt",
67
+ )
68
+ item = {
69
+ "input_ids": enc["input_ids"].squeeze(0),
70
+ "attention_mask": enc["attention_mask"].squeeze(0),
71
+ }
72
+ if "token_type_ids" in enc:
73
+ item["token_type_ids"] = enc["token_type_ids"].squeeze(0)
74
+ if not self.is_test:
75
+ item["label"] = torch.tensor(row["label"], dtype=torch.long)
76
+ return item
77
+
78
+
79
+ class MCQDatasetHF(Dataset):
80
+ """RoBERTa dataset (05_RoBERTa.ipynb) — AutoModelForMultipleChoice format:
81
+ 5 stacked (prompt, option) encodings per question."""
82
+
83
+ def __init__(self, df: pd.DataFrame, tokenizer, max_len: int, is_test: bool = False):
84
+ self.df = df.reset_index(drop=True)
85
+ self.tok = tokenizer
86
+ self.max_len = max_len
87
+ self.is_test = is_test
88
+
89
+ def __len__(self):
90
+ return len(self.df)
91
+
92
+ def __getitem__(self, idx):
93
+ row = self.df.iloc[idx]
94
+ prompt = row["prompt"]
95
+ options = [row[c] for c in OPTION_COLS]
96
+
97
+ enc = self.tok(
98
+ [prompt] * 5, options,
99
+ max_length=self.max_len, truncation=True, padding="max_length",
100
+ return_tensors="pt",
101
+ )
102
+ item = {
103
+ "input_ids": enc["input_ids"], # (5, max_len)
104
+ "attention_mask": enc["attention_mask"], # (5, max_len)
105
+ }
106
+ if not self.is_test:
107
+ item["labels"] = torch.tensor(ANSWER_MAP[row["answer"]], dtype=torch.long)
108
+ return item
src/ensemble.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ensemble.py
3
+ ===========
4
+ Weighted rank ensemble (06_ensemble.ipynb) — combines TF-IDF, LSTM, DeBERTa,
5
+ and RoBERTa top-3 predictions into one final top-3, using reciprocal-rank
6
+ scoring weighted by each model's trustworthiness.
7
+
8
+ For every model's top-3 list:
9
+ 1st choice -> weight * 1.0
10
+ 2nd choice -> weight * 0.5
11
+ 3rd choice -> weight * 0.333
12
+
13
+ Scores are summed across all 4 models; the 3 options with the highest
14
+ combined score become the final prediction, in order.
15
+ """
16
+
17
+ from collections import defaultdict
18
+ from typing import Dict, List
19
+
20
+ from .config import ENSEMBLE_WEIGHTS
21
+
22
+
23
+ def rank_ensemble(top3_by_model: Dict[str, List[str]],
24
+ weights: Dict[str, float] = None) -> List[str]:
25
+ """
26
+ Args:
27
+ top3_by_model: e.g. {
28
+ "tfidf": ["B", "A", "D"],
29
+ "lstm": ["B", "D", "C"],
30
+ "deberta": ["B", "A", "C"],
31
+ "roberta": ["A", "B", "D"],
32
+ }
33
+ weights: model_name -> weight (defaults to config.ENSEMBLE_WEIGHTS)
34
+
35
+ Returns:
36
+ Final top-3 option letters, e.g. ["B", "A", "D"]
37
+ """
38
+ weights = weights or ENSEMBLE_WEIGHTS
39
+ scores = defaultdict(float)
40
+
41
+ for model_name, top3 in top3_by_model.items():
42
+ w = weights.get(model_name, 0.0)
43
+ for rank, option in enumerate(top3):
44
+ scores[option] += w * (1.0 / (rank + 1))
45
+
46
+ best_3 = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:3]
47
+ return [option for option, _ in best_3]
48
+
49
+
50
+ def rank_ensemble_with_scores(top3_by_model: Dict[str, List[str]],
51
+ weights: Dict[str, float] = None):
52
+ """Same as rank_ensemble but also returns the raw per-option score dict,
53
+ useful for the Streamlit app to show a confidence breakdown."""
54
+ weights = weights or ENSEMBLE_WEIGHTS
55
+ scores = defaultdict(float)
56
+
57
+ for model_name, top3 in top3_by_model.items():
58
+ w = weights.get(model_name, 0.0)
59
+ for rank, option in enumerate(top3):
60
+ scores[option] += w * (1.0 / (rank + 1))
61
+
62
+ ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
63
+ top3_final = [option for option, _ in ranked[:3]]
64
+ return top3_final, dict(ranked)
src/inference.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ inference.py
3
+ ============
4
+ Loads all 4 trained models (TF-IDF, LSTM, DeBERTa, RoBERTa) and exposes a
5
+ single interface to get each model's top-3 prediction for one question.
6
+ Model loading is lazy + cached, so app.py only pays the load cost once.
7
+ """
8
+
9
+ from typing import Dict, List
10
+
11
+ from .models import TFIDFModel, LSTMModel, DeBERTaModel, RoBERTaModel
12
+ from .utils import get_logger
13
+
14
+ logger = get_logger(__name__)
15
+
16
+ # Module-level cache so Streamlit doesn't reload models on every rerun
17
+ _MODELS = {}
18
+
19
+
20
+ def load_all_models(which: List[str] = None) -> Dict[str, object]:
21
+ """
22
+ Load (or return cached) model instances.
23
+
24
+ Args:
25
+ which: subset of ["tfidf", "lstm", "deberta", "roberta"] to load.
26
+ Defaults to all four.
27
+ """
28
+ which = which or ["tfidf", "lstm", "deberta", "roberta"]
29
+
30
+ loaders = {
31
+ "tfidf": TFIDFModel,
32
+ "lstm": LSTMModel,
33
+ "deberta": DeBERTaModel,
34
+ "roberta": RoBERTaModel,
35
+ }
36
+
37
+ for name in which:
38
+ if name not in _MODELS:
39
+ logger.info(f"Loading {name} model...")
40
+ _MODELS[name] = loaders[name]().load()
41
+ logger.info(f"{name} model loaded.")
42
+
43
+ return {name: _MODELS[name] for name in which}
44
+
45
+
46
+ def predict_single_model(model_name: str, prompt: str, options: List[str]) -> List[str]:
47
+ """Run one model on one question, return its top-3 option letters."""
48
+ models = load_all_models([model_name])
49
+ return models[model_name].predict_top3_single(prompt, options)
50
+
51
+
52
+ def predict_all_models(prompt: str, options: List[str]) -> Dict[str, List[str]]:
53
+ """
54
+ Run all 4 models on one question.
55
+
56
+ Returns:
57
+ {
58
+ "tfidf": ["B", "A", "D"],
59
+ "lstm": ["B", "D", "C"],
60
+ "deberta": ["B", "A", "C"],
61
+ "roberta": ["A", "B", "D"],
62
+ }
63
+ """
64
+ models = load_all_models()
65
+ return {
66
+ name: model.predict_top3_single(prompt, options)
67
+ for name, model in models.items()
68
+ }
src/metrics.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ metrics.py
3
+ ==========
4
+ MAP@3 (Mean Average Precision @ 3) — the competition metric used identically
5
+ across all 6 notebooks:
6
+
7
+ Position of correct answer | Score
8
+ ----------------------------|-------
9
+ 1st | 1.00
10
+ 2nd | 0.50
11
+ 3rd | 0.33
12
+ Not in top-3 | 0.00
13
+ """
14
+
15
+ from typing import Sequence
16
+ import numpy as np
17
+
18
+ try:
19
+ import torch
20
+ except ImportError: # torch not required for the TF-IDF-only path
21
+ torch = None
22
+
23
+
24
+ def map_at_3(y_true: Sequence[int], y_proba: np.ndarray) -> float:
25
+ """
26
+ MAP@3 from a numpy probability matrix.
27
+
28
+ Args:
29
+ y_true: 1D array/list of true integer class labels (0-4)
30
+ y_proba: 2D array (n_samples, n_classes) of predicted probabilities
31
+
32
+ Returns:
33
+ float MAP@3 score
34
+ """
35
+ scores = []
36
+ for i, true in enumerate(y_true):
37
+ top3 = np.argsort(y_proba[i])[-3:][::-1]
38
+ hit = np.where(top3 == true)[0]
39
+ score = 1.0 / (hit[0] + 1) if len(hit) else 0.0
40
+ scores.append(score)
41
+ return float(np.mean(scores))
42
+
43
+
44
+ def map_at_3_from_logits(logits, labels) -> float:
45
+ """
46
+ Same metric, but takes raw torch logits + integer label tensor
47
+ (used in the LSTM and RoBERTa training loops).
48
+ """
49
+ if torch is None:
50
+ raise ImportError("torch is required for map_at_3_from_logits")
51
+ probs = torch.softmax(logits, dim=1).cpu().numpy()
52
+ labels = labels.cpu().numpy()
53
+ return map_at_3(labels, probs)
54
+
55
+
56
+ def validate_submission_format(predictions: Sequence[str]) -> int:
57
+ """
58
+ Sanity check used at the end of every notebook: every prediction string
59
+ must be exactly 3 space-separated letters from A-E.
60
+
61
+ Returns the number of malformed rows (should be 0).
62
+ """
63
+ errors = sum(
64
+ 1
65
+ for p in predictions
66
+ if len(str(p).split()) != 3 or not all(c in "ABCDE" for c in str(p).split())
67
+ )
68
+ return errors
src/models/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .tfidf import TFIDFModel
2
+ from .lstm import LSTMModel, LSTMClassifier, MCQTokenizer
3
+ from .deberta import DeBERTaModel, DeBERTaOptionScorer
4
+ from .roberta import RoBERTaModel
5
+
6
+ __all__ = [
7
+ "TFIDFModel",
8
+ "LSTMModel", "LSTMClassifier", "MCQTokenizer",
9
+ "DeBERTaModel", "DeBERTaOptionScorer",
10
+ "RoBERTaModel",
11
+ ]
src/models/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (475 Bytes). View file
 
src/models/__pycache__/deberta.cpython-312.pyc ADDED
Binary file (6.22 kB). View file
 
src/models/__pycache__/lstm.cpython-312.pyc ADDED
Binary file (8.67 kB). View file
 
src/models/__pycache__/roberta.cpython-312.pyc ADDED
Binary file (4.19 kB). View file
 
src/models/__pycache__/tfidf.cpython-312.pyc ADDED
Binary file (3.23 kB). View file
 
src/models/deberta.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ models/deberta.py
3
+ ==================
4
+ DeBERTa-v3-small binary option scorer (04_DeBERTa.ipynb).
5
+
6
+ Approach: each (prompt, option) pair is scored independently as
7
+ "wrong" vs "correct". At inference we softmax and use P(correct)
8
+ to rank the 5 options for a question.
9
+
10
+ Architecture:
11
+ DeBERTa backbone (pretrained) -> [CLS] embedding (768-dim)
12
+ -> Dropout(0.3) -> Linear(768 -> 2) [wrong, correct]
13
+ """
14
+
15
+ from pathlib import Path
16
+ from typing import List
17
+
18
+ import numpy as np
19
+ import torch
20
+ import torch.nn as nn
21
+ from transformers import AutoTokenizer, AutoModel
22
+
23
+ from ..config import DEBERTA_CFG
24
+ from ..preprocessing import build_deberta_pairs_single
25
+ from ..utils import get_device
26
+
27
+
28
+ class DeBERTaOptionScorer(nn.Module):
29
+ """Exact copy of the notebook's model class."""
30
+
31
+ def __init__(self, model_name: str, dropout: float = 0.3):
32
+ super().__init__()
33
+ # Force fp32: some HF checkpoints ship fp16 weights by default under
34
+ # torch_dtype="auto", which then mismatches our fp32 classification
35
+ # head (RuntimeError: mat1 and mat2 must have the same dtype).
36
+ self.backbone = AutoModel.from_pretrained(model_name, torch_dtype=torch.float32)
37
+ hidden_size = self.backbone.config.hidden_size
38
+ self.drop = nn.Dropout(dropout)
39
+ self.fc = nn.Linear(hidden_size, 2) # binary: wrong vs correct
40
+
41
+ def forward(self, input_ids, attention_mask, token_type_ids=None):
42
+ kwargs = dict(input_ids=input_ids, attention_mask=attention_mask)
43
+ if token_type_ids is not None:
44
+ kwargs["token_type_ids"] = token_type_ids
45
+
46
+ out = self.backbone(**kwargs)
47
+ cls = out.last_hidden_state[:, 0, :] # [CLS] token representation
48
+ return self.fc(self.drop(cls)) # (B, 2)
49
+
50
+
51
+ class DeBERTaModel:
52
+ """Convenience wrapper: loads tokenizer + checkpoint, scores all 5 options."""
53
+
54
+ def __init__(self, checkpoint_path: Path = None, device=None):
55
+ self.checkpoint_path = Path(checkpoint_path or DEBERTA_CFG["checkpoint_path"])
56
+ self.device = device or get_device()
57
+ self.tokenizer = None
58
+ self.model: DeBERTaOptionScorer = None
59
+
60
+ def load(self) -> "DeBERTaModel":
61
+ self.tokenizer = AutoTokenizer.from_pretrained(DEBERTA_CFG["model_name"])
62
+ self.model = DeBERTaOptionScorer(
63
+ DEBERTA_CFG["model_name"], dropout=DEBERTA_CFG["dropout"]
64
+ ).to(self.device)
65
+ self.model.load_state_dict(torch.load(self.checkpoint_path, map_location=self.device))
66
+ self.model.eval()
67
+ return self
68
+
69
+ @torch.no_grad()
70
+ def predict_proba_single(self, prompt: str, options: List[str]) -> np.ndarray:
71
+ """
72
+ Returns a (5,) array of P(correct) scores, one per option (A-E order).
73
+ NOTE: these 5 scores are independent sigmoid-like scores, not a
74
+ joint softmax over the 5 options (unlike RoBERTa) — that mirrors
75
+ exactly how the notebook ranks options at inference time.
76
+ """
77
+ if self.model is None:
78
+ self.load()
79
+
80
+ pair_texts = build_deberta_pairs_single(prompt, options)
81
+ scores = []
82
+ for text in pair_texts:
83
+ enc = self.tokenizer(
84
+ text,
85
+ max_length=DEBERTA_CFG["max_len"],
86
+ truncation=True,
87
+ padding="max_length",
88
+ return_tensors="pt",
89
+ )
90
+ input_ids = enc["input_ids"].to(self.device)
91
+ attention_mask = enc["attention_mask"].to(self.device)
92
+ token_type_ids = enc.get("token_type_ids")
93
+ if token_type_ids is not None:
94
+ token_type_ids = token_type_ids.to(self.device)
95
+
96
+ logits = self.model(input_ids, attention_mask, token_type_ids)
97
+ p_correct = torch.softmax(logits, dim=1)[:, 1].cpu().numpy()[0]
98
+ scores.append(p_correct)
99
+
100
+ return np.array(scores)
101
+
102
+ def predict_top3_single(self, prompt: str, options: List[str]) -> List[str]:
103
+ from ..config import REVERSE_MAP
104
+ scores = self.predict_proba_single(prompt, options)
105
+ top3_idx = np.argsort(scores)[-3:][::-1]
106
+ return [REVERSE_MAP[i] for i in top3_idx]
src/models/lstm.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ models/lstm.py
3
+ ==============
4
+ Custom word-level tokenizer + Bidirectional LSTM classifier
5
+ (03_lstm.ipynb — the nn.LSTM-backed production version, not the
6
+ from-scratch educational one, since that's what was actually trained).
7
+
8
+ Architecture:
9
+ [Input IDs] -> Embedding -> BiLSTM x2 -> Dropout -> Linear -> 5 logits
10
+ """
11
+
12
+ from collections import Counter
13
+ from pathlib import Path
14
+ from typing import List
15
+
16
+ import numpy as np
17
+ import torch
18
+ import torch.nn as nn
19
+
20
+ from ..config import LSTM_CFG
21
+ from ..preprocessing import build_lstm_text
22
+ from ..utils import load_pickle, get_device
23
+
24
+
25
+ class MCQTokenizer:
26
+ """Simple whitespace word-level tokenizer with a fixed-size vocabulary."""
27
+
28
+ PAD_TOKEN = "<PAD>"
29
+ UNK_TOKEN = "<UNK>"
30
+
31
+ def __init__(self, vocab_size: int = 10000):
32
+ self.vocab_size = vocab_size
33
+ self.word2idx = {self.PAD_TOKEN: 0, self.UNK_TOKEN: 1}
34
+ self.idx2word = {0: self.PAD_TOKEN, 1: self.UNK_TOKEN}
35
+ self.vocab_built = False
36
+
37
+ def tokenize(self, text: str) -> List[str]:
38
+ return str(text).lower().split()
39
+
40
+ def build_vocab(self, texts: List[str]):
41
+ counter = Counter()
42
+ for text in texts:
43
+ counter.update(self.tokenize(text))
44
+
45
+ most_common = counter.most_common(self.vocab_size - 2)
46
+ for idx, (word, _) in enumerate(most_common, start=2):
47
+ self.word2idx[word] = idx
48
+ self.idx2word[idx] = word
49
+ self.vocab_built = True
50
+
51
+ def encode(self, text: str) -> List[int]:
52
+ words = self.tokenize(text)
53
+ return [self.word2idx.get(w, 1) for w in words]
54
+
55
+ def pad_or_truncate(self, ids: List[int], max_len: int) -> List[int]:
56
+ if len(ids) >= max_len:
57
+ return ids[:max_len]
58
+ return ids + [0] * (max_len - len(ids))
59
+
60
+
61
+ class LSTMClassifier(nn.Module):
62
+ """Embedding -> BiLSTM x2 -> Dropout -> Linear head. Exact copy from the notebook."""
63
+
64
+ def __init__(self, vocab_size, embed_dim, hidden_dim, num_layers,
65
+ num_classes, dropout, pad_idx: int = 0):
66
+ super().__init__()
67
+
68
+ self.embedding = nn.Embedding(
69
+ num_embeddings=vocab_size, embedding_dim=embed_dim, padding_idx=pad_idx
70
+ )
71
+ self.lstm = nn.LSTM(
72
+ input_size=embed_dim,
73
+ hidden_size=hidden_dim,
74
+ num_layers=num_layers,
75
+ batch_first=True,
76
+ bidirectional=True,
77
+ dropout=dropout if num_layers > 1 else 0.0,
78
+ )
79
+ self.dropout = nn.Dropout(dropout)
80
+ self.fc = nn.Linear(hidden_dim * 2, num_classes)
81
+
82
+ def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
83
+ embedded = self.embedding(input_ids)
84
+ embedded = self.dropout(embedded)
85
+
86
+ output, (hidden, cell) = self.lstm(embedded)
87
+
88
+ forward_hidden = hidden[-2] # last layer, forward direction
89
+ backward_hidden = hidden[-1] # last layer, backward direction
90
+ combined = torch.cat([forward_hidden, backward_hidden], dim=1)
91
+
92
+ out = self.dropout(combined)
93
+ logits = self.fc(out)
94
+ return logits
95
+
96
+
97
+ class LSTMModel:
98
+ """Convenience wrapper: loads tokenizer + trained checkpoint, predicts on raw text."""
99
+
100
+ def __init__(self, checkpoint_path: Path = None, tokenizer_path: Path = None, device=None):
101
+ self.checkpoint_path = Path(checkpoint_path or LSTM_CFG["checkpoint_path"])
102
+ self.tokenizer_path = Path(tokenizer_path or LSTM_CFG["tokenizer_path"])
103
+ self.device = device or get_device()
104
+ self.tokenizer: MCQTokenizer = None
105
+ self.model: LSTMClassifier = None
106
+
107
+ def load(self) -> "LSTMModel":
108
+ # The tokenizer was pickled from inside a notebook, where MCQTokenizer
109
+ # lived in the __main__ module. When unpickling from any other script
110
+ # (like this one, or app.py), Python looks for MCQTokenizer in
111
+ # __main__ and fails unless we register it there first.
112
+ import sys
113
+ sys.modules["__main__"].MCQTokenizer = MCQTokenizer
114
+
115
+ self.tokenizer = load_pickle(self.tokenizer_path)
116
+ self.model = LSTMClassifier(
117
+ vocab_size=LSTM_CFG["vocab_size"],
118
+ embed_dim=LSTM_CFG["embed_dim"],
119
+ hidden_dim=LSTM_CFG["hidden_dim"],
120
+ num_layers=LSTM_CFG["num_layers"],
121
+ num_classes=LSTM_CFG["num_classes"],
122
+ dropout=LSTM_CFG["dropout"],
123
+ ).to(self.device)
124
+ self.model.load_state_dict(torch.load(self.checkpoint_path, map_location=self.device))
125
+ self.model.eval()
126
+ return self
127
+
128
+ @torch.no_grad()
129
+ def predict_proba_single(self, prompt: str, options: List[str]) -> np.ndarray:
130
+ if self.model is None:
131
+ self.load()
132
+ text = build_lstm_text(prompt, options)
133
+ ids = self.tokenizer.encode(text)
134
+ ids = self.tokenizer.pad_or_truncate(ids, LSTM_CFG["max_len"])
135
+ id_tensor = torch.tensor(ids, dtype=torch.long).unsqueeze(0).to(self.device)
136
+
137
+ logits = self.model(id_tensor)
138
+ proba = torch.softmax(logits, dim=1).cpu().numpy()[0]
139
+ return proba
140
+
141
+ def predict_top3_single(self, prompt: str, options: List[str]) -> List[str]:
142
+ from ..config import REVERSE_MAP
143
+ proba = self.predict_proba_single(prompt, options)
144
+ top3_idx = np.argsort(proba)[-3:][::-1]
145
+ return [REVERSE_MAP[i] for i in top3_idx]
src/models/roberta.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ models/roberta.py
3
+ ==================
4
+ RoBERTa via AutoModelForMultipleChoice (05_RoBERTa.ipynb).
5
+
6
+ Unlike DeBERTa (5 independent binary passes), RoBERTa sees all 5 options
7
+ together and produces one joint softmax over the 5 choices — the
8
+ multiple-choice head is built into `AutoModelForMultipleChoice`, no
9
+ custom classification head needed.
10
+ """
11
+
12
+ from pathlib import Path
13
+ from typing import List
14
+
15
+ import numpy as np
16
+ import torch
17
+ from transformers import AutoTokenizer, AutoModelForMultipleChoice
18
+
19
+ from ..config import ROBERTA_CFG
20
+ from ..preprocessing import build_roberta_inputs_single
21
+ from ..utils import get_device
22
+
23
+
24
+ class RoBERTaModel:
25
+ """Convenience wrapper: loads tokenizer + checkpoint, predicts option probs."""
26
+
27
+ def __init__(self, checkpoint_path: Path = None, device=None):
28
+ self.checkpoint_path = Path(checkpoint_path or ROBERTA_CFG["checkpoint_path"])
29
+ self.device = device or get_device()
30
+ self.tokenizer = None
31
+ self.model: AutoModelForMultipleChoice = None
32
+
33
+ def load(self) -> "RoBERTaModel":
34
+ self.tokenizer = AutoTokenizer.from_pretrained(ROBERTA_CFG["model_name"])
35
+ self.model = AutoModelForMultipleChoice.from_pretrained(
36
+ ROBERTA_CFG["model_name"]
37
+ ).to(self.device)
38
+ self.model.load_state_dict(torch.load(self.checkpoint_path, map_location=self.device))
39
+ self.model.eval()
40
+ return self
41
+
42
+ @torch.no_grad()
43
+ def predict_proba_single(self, prompt: str, options: List[str]) -> np.ndarray:
44
+ """Returns a (5,) joint-softmax probability array in A-E order."""
45
+ if self.model is None:
46
+ self.load()
47
+
48
+ prompts_x5, options_x5 = build_roberta_inputs_single(prompt, options)
49
+ enc = self.tokenizer(
50
+ prompts_x5, options_x5,
51
+ max_length=ROBERTA_CFG["max_len"],
52
+ truncation=True,
53
+ padding="max_length",
54
+ return_tensors="pt",
55
+ )
56
+ # Reshape to (batch=1, num_choices=5, seq_len) as AutoModelForMultipleChoice expects
57
+ input_ids = enc["input_ids"].unsqueeze(0).to(self.device)
58
+ attention_mask = enc["attention_mask"].unsqueeze(0).to(self.device)
59
+
60
+ out = self.model(input_ids=input_ids, attention_mask=attention_mask)
61
+ proba = torch.softmax(out.logits, dim=1).cpu().numpy()[0]
62
+ return proba
63
+
64
+ def predict_top3_single(self, prompt: str, options: List[str]) -> List[str]:
65
+ from ..config import REVERSE_MAP
66
+ proba = self.predict_proba_single(prompt, options)
67
+ top3_idx = np.argsort(proba)[-3:][::-1]
68
+ return [REVERSE_MAP[i] for i in top3_idx]
src/models/tfidf.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ models/tfidf.py
3
+ ================
4
+ TF-IDF + Logistic Regression baseline wrapper (02_baseline.ipynb).
5
+ Loads the pickled vectorizer + classifier and exposes a predict_proba()
6
+ that returns per-option probabilities in A-E order.
7
+ """
8
+
9
+ from pathlib import Path
10
+ from typing import List
11
+
12
+ import numpy as np
13
+ from sklearn.feature_extraction.text import TfidfVectorizer
14
+ from sklearn.linear_model import LogisticRegression
15
+
16
+ from ..config import TFIDF_CFG
17
+ from ..preprocessing import build_tfidf_text_single
18
+ from ..utils import load_pickle
19
+
20
+
21
+ class TFIDFModel:
22
+ """Wraps the fitted TfidfVectorizer + LogisticRegression pair."""
23
+
24
+ def __init__(self, model_path: Path = None, vectorizer_path: Path = None,
25
+ strategy: str = None):
26
+ self.model_path = Path(model_path or TFIDF_CFG["model_path"])
27
+ self.vectorizer_path = Path(vectorizer_path or TFIDF_CFG["vectorizer_path"])
28
+ self.strategy = strategy or TFIDF_CFG["text_strategy"]
29
+ self.model: LogisticRegression = None
30
+ self.vectorizer: TfidfVectorizer = None
31
+
32
+ def load(self) -> "TFIDFModel":
33
+ self.model = load_pickle(self.model_path)
34
+ self.vectorizer = load_pickle(self.vectorizer_path)
35
+ return self
36
+
37
+ def predict_proba_single(self, prompt: str, options: List[str]) -> np.ndarray:
38
+ """Returns a (5,) probability array in A-E order for one question."""
39
+ if self.model is None or self.vectorizer is None:
40
+ self.load()
41
+ text = build_tfidf_text_single(prompt, options, strategy=self.strategy)
42
+ X = self.vectorizer.transform([text])
43
+ proba = self.model.predict_proba(X)[0]
44
+ return proba
45
+
46
+ def predict_top3_single(self, prompt: str, options: List[str]) -> List[str]:
47
+ """Returns top-3 option letters (e.g. ['B', 'D', 'A']) for one question."""
48
+ from ..config import REVERSE_MAP
49
+ proba = self.predict_proba_single(prompt, options)
50
+ top3_idx = np.argsort(proba)[-3:][::-1]
51
+ return [REVERSE_MAP[i] for i in top3_idx]
src/predict.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ predict.py
3
+ ==========
4
+ The single public entrypoint used by app.py. Everything else in src/ is
5
+ plumbing — this is the one function a Streamlit UI (or any other frontend)
6
+ needs to call.
7
+ """
8
+
9
+ from typing import List, Dict
10
+
11
+ from .inference import predict_all_models
12
+ from .ensemble import rank_ensemble_with_scores
13
+ from .config import LEADERBOARD_SCORES
14
+
15
+
16
+ def predict(prompt: str, options: List[str]) -> Dict:
17
+ """
18
+ Full pipeline: run all 4 models -> ensemble -> return everything the UI
19
+ needs to render (final answer, per-model breakdown, confidence scores).
20
+
21
+ Args:
22
+ prompt: the question text
23
+ options: list of 5 option strings, in A, B, C, D, E order
24
+
25
+ Returns:
26
+ {
27
+ "final_top3": ["B", "A", "D"],
28
+ "per_model_top3": {
29
+ "tfidf": [...], "lstm": [...], "deberta": [...], "roberta": [...]
30
+ },
31
+ "ensemble_scores": {"B": 0.62, "A": 0.41, ...},
32
+ "leaderboard_scores": {...} # for display / transparency
33
+ }
34
+ """
35
+ if len(options) != 5:
36
+ raise ValueError(f"Expected exactly 5 options (A-E), got {len(options)}")
37
+
38
+ per_model_top3 = predict_all_models(prompt, options)
39
+ final_top3, ensemble_scores = rank_ensemble_with_scores(per_model_top3)
40
+
41
+ return {
42
+ "final_top3": final_top3,
43
+ "per_model_top3": per_model_top3,
44
+ "ensemble_scores": ensemble_scores,
45
+ "leaderboard_scores": LEADERBOARD_SCORES,
46
+ }
src/preprocessing.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ preprocessing.py
3
+ ================
4
+ Text cleaning and prompt-building functions, extracted from the notebooks.
5
+ Every model needs a slightly different input format:
6
+
7
+ - TF-IDF : one flat string per question (3 strategies tried)
8
+ - LSTM : prompt + all 5 options concatenated, word-tokenized
9
+ - DeBERTa : question expanded into 5 (prompt, single option) pairs
10
+ - RoBERTa : 5 stacked (prompt, option) pairs per question (multiple-choice)
11
+ """
12
+
13
+ from typing import List
14
+ import pandas as pd
15
+
16
+ from .config import OPTION_COLS, ANSWER_MAP
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Shared cleaning (identical in all 4 notebooks)
21
+ # ---------------------------------------------------------------------------
22
+ def clean_text(text: str) -> str:
23
+ """Lowercase + strip whitespace. Applied to every text column at load time."""
24
+ return str(text).lower().strip()
25
+
26
+
27
+ def clean_dataframe(df: pd.DataFrame, cols: List[str] = None) -> pd.DataFrame:
28
+ """Apply clean_text() to prompt + option columns of a dataframe (returns a copy)."""
29
+ cols = cols or (["prompt"] + OPTION_COLS)
30
+ df = df.copy()
31
+ for col in cols:
32
+ if col in df.columns:
33
+ df[col] = df[col].astype(str).str.lower().str.strip()
34
+ return df
35
+
36
+
37
+ def stratified_split(train_df: pd.DataFrame, val_size: float = 0.2, seed: int = 42):
38
+ """
39
+ Manual per-class stratified split, identical across all 4 training notebooks.
40
+ Keeps the per-answer-letter distribution the same in train and val.
41
+ """
42
+ import numpy as np
43
+
44
+ np.random.seed(seed)
45
+ train_idx, val_idx = [], []
46
+ for ans in "ABCDE":
47
+ idx = train_df[train_df["answer"] == ans].index.tolist()
48
+ np.random.shuffle(idx)
49
+ cut = int(len(idx) * (1.0 - val_size))
50
+ train_idx += idx[:cut]
51
+ val_idx += idx[cut:]
52
+
53
+ tr = train_df.loc[train_idx].reset_index(drop=True)
54
+ va = train_df.loc[val_idx].reset_index(drop=True)
55
+ return tr, va
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # TF-IDF text-builder strategies (02_baseline.ipynb)
60
+ # ---------------------------------------------------------------------------
61
+ def build_v1_simple(df: pd.DataFrame):
62
+ """prompt + all options concatenated (naive baseline)"""
63
+ return (df["prompt"] + " " + df["A"] + " " + df["B"] + " " +
64
+ df["C"] + " " + df["D"] + " " + df["E"]).values
65
+
66
+
67
+ def build_v2_repeated(df: pd.DataFrame):
68
+ """prompt repeated before EACH option: 'Q optA Q optB Q optC Q optD Q optE'"""
69
+ q = df["prompt"]
70
+ return (q + " " + df["A"] + " " + q + " " + df["B"] + " " +
71
+ q + " " + df["C"] + " " + q + " " + df["D"] + " " + q + " " + df["E"]).values
72
+
73
+
74
+ def build_v3_labeled(df: pd.DataFrame):
75
+ """explicit 'option a: ...' labels + trigrams — best-performing strategy"""
76
+ return (df["prompt"] +
77
+ " option a: " + df["A"] +
78
+ " option b: " + df["B"] +
79
+ " option c: " + df["C"] +
80
+ " option d: " + df["D"] +
81
+ " option e: " + df["E"]).values
82
+
83
+
84
+ TEXT_BUILDERS = {
85
+ "v1_simple": build_v1_simple,
86
+ "v2_repeated": build_v2_repeated,
87
+ "v3_labeled": build_v3_labeled,
88
+ }
89
+
90
+
91
+ def build_tfidf_text(df: pd.DataFrame, strategy: str = "v3_labeled"):
92
+ """Dispatch to the chosen TF-IDF text-builder strategy."""
93
+ return TEXT_BUILDERS[strategy](df)
94
+
95
+
96
+ def build_tfidf_text_single(prompt: str, options: List[str], strategy: str = "v3_labeled") -> str:
97
+ """Same as build_tfidf_text but for a single question (used at inference time)."""
98
+ a, b, c, d, e = options
99
+ prompt = clean_text(prompt)
100
+ a, b, c, d, e = [clean_text(x) for x in (a, b, c, d, e)]
101
+
102
+ if strategy == "v1_simple":
103
+ return f"{prompt} {a} {b} {c} {d} {e}"
104
+ if strategy == "v2_repeated":
105
+ return (f"{prompt} {a} {prompt} {b} {prompt} {c} "
106
+ f"{prompt} {d} {prompt} {e}")
107
+ if strategy == "v3_labeled":
108
+ return (f"{prompt} option a: {a} option b: {b} option c: {c} "
109
+ f"option d: {d} option e: {e}")
110
+ raise ValueError(f"Unknown strategy: {strategy}")
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # LSTM: prompt + all options concatenated (03_lstm.ipynb)
115
+ # ---------------------------------------------------------------------------
116
+ def build_lstm_text(prompt: str, options: List[str]) -> str:
117
+ """Combined text used by the LSTM tokenizer: prompt + A + B + C + D + E."""
118
+ prompt = clean_text(prompt)
119
+ a, b, c, d, e = [clean_text(x) for x in options]
120
+ return f"{prompt} {a} {b} {c} {d} {e}"
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # DeBERTa: expand each question into 5 (prompt, option) pairs (04_DeBERTa.ipynb)
125
+ # ---------------------------------------------------------------------------
126
+ def expand_pairs(df: pd.DataFrame, is_test: bool = False) -> pd.DataFrame:
127
+ """
128
+ Convert each question row into 5 rows: one per option.
129
+ text = 'prompt option_text'
130
+ label = 1 if this option is correct else 0 (only when not is_test)
131
+ """
132
+ rows = []
133
+ for _, r in df.iterrows():
134
+ correct = r.get("answer", None)
135
+ for opt in OPTION_COLS:
136
+ rows.append({
137
+ "id": r["id"],
138
+ "option": opt,
139
+ "text": f"{r['prompt']} {r[opt]}",
140
+ "label": int(opt == correct) if not is_test else -1,
141
+ })
142
+ return pd.DataFrame(rows)
143
+
144
+
145
+ def build_deberta_pairs_single(prompt: str, options: List[str]) -> List[str]:
146
+ """Same expansion, but for a single question at inference time.
147
+ Returns a list of 5 strings, one per option, in A-E order."""
148
+ prompt = clean_text(prompt)
149
+ return [f"{prompt} {clean_text(opt)}" for opt in options]
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # RoBERTa: 5 stacked (prompt, option) pairs for AutoModelForMultipleChoice
154
+ # (05_RoBERTa.ipynb) — tokenizer handles the pairing, this just prepares lists
155
+ # ---------------------------------------------------------------------------
156
+ def build_roberta_inputs_single(prompt: str, options: List[str]):
157
+ """
158
+ Returns (prompts_x5, options_x5) ready to feed straight into the
159
+ tokenizer as tokenizer([prompt]*5, options, ...).
160
+ """
161
+ prompt = clean_text(prompt)
162
+ options = [clean_text(opt) for opt in options]
163
+ return [prompt] * 5, options
src/utils.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ utils.py
3
+ ========
4
+ Shared helpers used across every model module: reproducibility seeding,
5
+ device detection, checkpoint save/load, and a simple logger.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ import random
11
+ import pickle
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+
17
+ try:
18
+ import torch
19
+ except ImportError:
20
+ torch = None
21
+
22
+
23
+ def seed_everything(seed: int = 42) -> None:
24
+ """Set every RNG (python, numpy, torch) for reproducibility.
25
+ Matches the set_seed()/torch.manual_seed() calls in all training notebooks.
26
+ """
27
+ random.seed(seed)
28
+ np.random.seed(seed)
29
+ os.environ["PYTHONHASHSEED"] = str(seed)
30
+
31
+ if torch is not None:
32
+ torch.manual_seed(seed)
33
+ if torch.cuda.is_available():
34
+ torch.cuda.manual_seed_all(seed)
35
+ torch.backends.cudnn.deterministic = True
36
+ torch.backends.cudnn.benchmark = False
37
+
38
+
39
+ def get_device():
40
+ """Return 'cuda' if available else 'cpu' (same logic as every notebook)."""
41
+ if torch is None:
42
+ return "cpu"
43
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
44
+
45
+
46
+ def save_model(model, path: Path) -> None:
47
+ """Save a torch model's state_dict to disk (creates parent dirs)."""
48
+ path = Path(path)
49
+ path.parent.mkdir(parents=True, exist_ok=True)
50
+ torch.save(model.state_dict(), path)
51
+
52
+
53
+ def load_model(model, path: Path, device=None):
54
+ """Load a torch model's state_dict from disk in-place and return it in eval mode."""
55
+ device = device or get_device()
56
+ model.load_state_dict(torch.load(path, map_location=device))
57
+ model.to(device)
58
+ model.eval()
59
+ return model
60
+
61
+
62
+ def save_pickle(obj: Any, path: Path) -> None:
63
+ path = Path(path)
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+ with open(path, "wb") as f:
66
+ pickle.dump(obj, f)
67
+
68
+
69
+ def load_pickle(path: Path) -> Any:
70
+ with open(path, "rb") as f:
71
+ return pickle.load(f)
72
+
73
+
74
+ def get_logger(name: str = "smart_mcq_solver") -> logging.Logger:
75
+ """Simple stdout logger, reused everywhere instead of print()."""
76
+ logger = logging.getLogger(name)
77
+ if not logger.handlers:
78
+ handler = logging.StreamHandler()
79
+ formatter = logging.Formatter("[%(asctime)s] %(levelname)s - %(message)s", "%H:%M:%S")
80
+ handler.setFormatter(formatter)
81
+ logger.addHandler(handler)
82
+ logger.setLevel(logging.INFO)
83
+ return logger
src/wandb_tracker.py ADDED
File without changes