Spaces:
Running on Zero
Running on Zero
Rohan Kumar commited on
Commit ·
71e6ce1
1
Parent(s): b9a91bc
Deploy DeBERTa MCQ solver with Gradio
Browse files- README.md +17 -8
- app.py +68 -0
- outputs/checkpoints/deberta_best.pt +3 -0
- requirements.txt +5 -0
- src/__init__.py +5 -0
- src/__pycache__/__init__.cpython-312.pyc +0 -0
- src/__pycache__/config.cpython-312.pyc +0 -0
- src/__pycache__/ensemble.cpython-312.pyc +0 -0
- src/__pycache__/inference.cpython-312.pyc +0 -0
- src/__pycache__/metrics.cpython-312.pyc +0 -0
- src/__pycache__/predict.cpython-312.pyc +0 -0
- src/__pycache__/preprocessing.cpython-312.pyc +0 -0
- src/__pycache__/utils.cpython-312.pyc +0 -0
- src/config.py +121 -0
- src/dataset.py +108 -0
- src/ensemble.py +64 -0
- src/inference.py +68 -0
- src/metrics.py +68 -0
- src/models/__init__.py +11 -0
- src/models/__pycache__/__init__.cpython-312.pyc +0 -0
- src/models/__pycache__/deberta.cpython-312.pyc +0 -0
- src/models/__pycache__/lstm.cpython-312.pyc +0 -0
- src/models/__pycache__/roberta.cpython-312.pyc +0 -0
- src/models/__pycache__/tfidf.cpython-312.pyc +0 -0
- src/models/deberta.py +106 -0
- src/models/lstm.py +145 -0
- src/models/roberta.py +68 -0
- src/models/tfidf.py +51 -0
- src/predict.py +46 -0
- src/preprocessing.py +163 -0
- src/utils.py +83 -0
- src/wandb_tracker.py +0 -0
README.md
CHANGED
|
@@ -1,14 +1,23 @@
|
|
| 1 |
---
|
| 2 |
-
title: Smart
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
-
license: mit
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|