aaa / training /train_answer_scorer.py
work-sejal
Deploy AI service with FastAPI
70ea7be
Raw
History Blame Contribute Delete
10.8 kB
"""Answer Scorer training pipeline.
Trains a TF-IDF + Ridge regression model for subjective answer scoring.
Target: teacher_marks (continuous). Features: TF-IDF of student_answer +
rubric_match_score + concept_coverage_score. Primary metric: MAE.
Predictions are clipped to [0, max_marks] range.
"""
import logging
from datetime import datetime, timezone
import numpy as np
import pandas as pd
import scipy.sparse
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
from app.core.config import settings
from app.core.exceptions import TrainingError
from training.base_trainer import BaseTrainer, TrainingResult
logger = logging.getLogger(__name__)
class AnswerScorerTrainer(BaseTrainer):
"""TF-IDF + Ridge regression for subjective answer scoring.
Target: teacher_marks (continuous)
Features: TF-IDF of student_answer + rubric_match_score + concept_coverage_score
Primary metric: MAE
"""
@property
def model_name(self) -> str:
return "answer_scorer"
@property
def model_version(self) -> str:
return "answer_scorer_v2_baseline_001"
@property
def table_name(self) -> str:
return "training_answer_scoring"
def train(self, train_df: pd.DataFrame, val_df: pd.DataFrame) -> dict:
"""Train TF-IDF + Ridge regression.
Algorithm:
1. Fit TF-IDF vectorizer on train student_answer text
2. Build feature matrix: hstack([tfidf_features, rubric_match_score, concept_coverage_score])
3. Target: teacher_marks
4. Fit Ridge(alpha=1.0, random_state=seed)
5. Return {"model": ridge, "vectorizer": tfidf}
"""
tfidf = TfidfVectorizer(
max_features=5000,
ngram_range=(1, 2),
)
tfidf_features = tfidf.fit_transform(train_df["student_answer"])
numeric_features = scipy.sparse.csr_matrix(
train_df[["rubric_match_score", "concept_coverage_score"]].values
)
X_train = scipy.sparse.hstack([tfidf_features, numeric_features])
y_train = train_df["teacher_marks"].values
ridge = Ridge(alpha=1.0, random_state=self._seed)
ridge.fit(X_train, y_train)
logger.info(
"Answer Scorer trained — %d TF-IDF features + 2 numeric features, %d samples",
tfidf_features.shape[1],
X_train.shape[0],
)
return {"model": ridge, "vectorizer": tfidf}
def evaluate(self, artifacts: dict, df: pd.DataFrame, split_name: str) -> dict:
"""Evaluate model on a split.
Computes: MAE, RMSE, R-squared, distribution comparison (mean/std of
predicted vs actual), percentage of predictions where
|predicted - actual| > 1.0 (teacher review threshold).
Predictions are clipped to [0, max_marks] range.
"""
model = artifacts["model"]
tfidf = artifacts["vectorizer"]
tfidf_features = tfidf.transform(df["student_answer"])
numeric_features = scipy.sparse.csr_matrix(
df[["rubric_match_score", "concept_coverage_score"]].values
)
X = scipy.sparse.hstack([tfidf_features, numeric_features])
y_true = df["teacher_marks"].values
max_marks = df["max_marks"].values
# Predict and clip to [0, max_marks] range
y_pred_raw = model.predict(X)
y_pred = np.clip(y_pred_raw, 0, max_marks)
# Core metrics
mae = mean_absolute_error(y_true, y_pred)
rmse = float(np.sqrt(mean_squared_error(y_true, y_pred)))
r_squared = r2_score(y_true, y_pred)
# Distribution comparison
pred_mean = float(np.mean(y_pred))
pred_std = float(np.std(y_pred))
actual_mean = float(np.mean(y_true))
actual_std = float(np.std(y_true))
# Teacher review threshold: percentage where |predicted - actual| > 1.0
abs_errors = np.abs(y_pred - y_true)
pct_above_threshold = float(np.mean(abs_errors > 1.0) * 100)
metrics = {
"mae": round(mae, 4),
"rmse": round(rmse, 4),
"r_squared": round(r_squared, 4),
"distribution": {
"predicted_mean": round(pred_mean, 4),
"predicted_std": round(pred_std, 4),
"actual_mean": round(actual_mean, 4),
"actual_std": round(actual_std, 4),
},
"pct_above_review_threshold": round(pct_above_threshold, 2),
}
logger.info(
"%s metrics — MAE: %.4f, RMSE: %.4f, R²: %.4f, %%>1.0: %.2f%%",
split_name, mae, rmse, r_squared, pct_above_threshold,
)
return metrics
def _check_baseline(self, metrics: dict) -> None:
"""Verify MAE < max_marks/2 (very lenient baseline for synthetic data).
Uses the test metrics MAE. The max_marks average is estimated from the
dataset; for a lenient check we use a generous threshold.
"""
test_metrics = metrics.get("metrics", {}).get("test", {})
mae = test_metrics.get("mae", float("inf"))
# Fallback to validation if test not available
if mae == float("inf"):
val_metrics = metrics.get("metrics", {}).get("validation", {})
mae = val_metrics.get("mae", float("inf"))
# Use max_marks / 2 as baseline. Since max_marks varies per question,
# we use a conservative estimate. Typical max_marks in the dataset is 5.
# A lenient baseline: MAE < 2.5 (half of typical max_marks=5)
max_marks_half = 2.5
if mae >= max_marks_half:
raise TrainingError(
f"MAE ({mae:.4f}) does not meet baseline threshold "
f"({max_marks_half:.1f} = max_marks/2). "
f"Model is not better than naive prediction.",
model_name=self.model_name,
)
logger.info(
"Baseline check passed — MAE %.4f < baseline %.1f",
mae, max_marks_half,
)
def _build_metrics(
self,
val_metrics: dict,
test_metrics: dict,
train_df: pd.DataFrame,
val_df: pd.DataFrame,
test_df: pd.DataFrame,
) -> dict:
"""Assemble full metrics.json content."""
return {
"model_name": self.model_name,
"model_version": self.model_version,
"dataset_version": settings.ai_service_version,
"trained_at": datetime.now(timezone.utc).isoformat(),
"seed": self._seed,
"split_counts": {
"train": len(train_df),
"validation": len(val_df),
"test": len(test_df),
},
"metrics": {
"validation": val_metrics,
"test": test_metrics,
},
"limitations": [
"Trained on synthetic data only.",
"TF-IDF features do not capture deep semantic similarity.",
"Predictions clipped to [0, max_marks] range.",
"teacher_review_required is always True in V2 baseline.",
"MAE is the primary metric; individual predictions may deviate significantly.",
],
}
def _build_training_config(
self,
train_df: pd.DataFrame,
val_df: pd.DataFrame,
test_df: pd.DataFrame,
) -> dict:
"""Build training_config.json with hyperparameters."""
return {
"model_name": self.model_name,
"model_version": self.model_version,
"dataset_version": settings.ai_service_version,
"seed": self._seed,
"split_counts": {
"train": len(train_df),
"validation": len(val_df),
"test": len(test_df),
},
"hyperparameters": {
"tfidf_max_features": 5000,
"ngram_range": [1, 2],
"ridge_alpha": 1.0,
},
"feature_columns": [
"student_answer (TF-IDF)",
"rubric_match_score",
"concept_coverage_score",
],
"target_column": "teacher_marks",
"algorithm": "TF-IDF + Ridge regression",
}
def _build_model_card(self, metrics: dict) -> str:
"""Generate model_card.md content."""
val_metrics = metrics.get("metrics", {}).get("validation", {})
test_metrics = metrics.get("metrics", {}).get("test", {})
card = f"""# Model Card: Answer Scorer
## Model Details
- **Model Name:** {self.model_name}
- **Model Version:** {self.model_version}
- **Algorithm:** TF-IDF + Ridge Regression
- **Framework:** scikit-learn
- **Trained At:** {metrics.get("trained_at", "N/A")}
- **Seed:** {self._seed}
## Intended Use
Score subjective student answers against a rubric and model answer.
Produces a predicted marks value clipped to [0, max_marks]. Always sets
teacher_review_required=True in V2 baseline — predictions are advisory only.
## Training Data
- **Source:** training_answer_scoring.csv (synthetic dataset v2)
- **Split Counts:** train={metrics.get("split_counts", {}).get("train", "N/A")}, \
validation={metrics.get("split_counts", {}).get("validation", "N/A")}, \
test={metrics.get("split_counts", {}).get("test", "N/A")}
- **Features:** student_answer (TF-IDF, max_features=5000, ngram_range=(1,2)) + \
rubric_match_score + concept_coverage_score
- **Target:** teacher_marks (continuous)
## Metrics
### Validation Set
- MAE: {val_metrics.get("mae", "N/A")}
- RMSE: {val_metrics.get("rmse", "N/A")}
- R-squared: {val_metrics.get("r_squared", "N/A")}
- % Above Review Threshold (>1.0): {val_metrics.get("pct_above_review_threshold", "N/A")}%
### Test Set
- MAE: {test_metrics.get("mae", "N/A")}
- RMSE: {test_metrics.get("rmse", "N/A")}
- R-squared: {test_metrics.get("r_squared", "N/A")}
- % Above Review Threshold (>1.0): {test_metrics.get("pct_above_review_threshold", "N/A")}%
## Known Limitations
- Trained on synthetic data only — performance on real student answers is unknown.
- TF-IDF features do not capture deep semantic similarity or paraphrasing.
- Predictions are clipped to [0, max_marks]; the model may predict outside this range before clipping.
- teacher_review_required is always True in V2 baseline.
- MAE is the primary metric; individual predictions may deviate significantly from teacher marks.
## Fallback Behavior
When the model is not loaded or confidence is below threshold,
the system falls back to rubric keyword coverage + length heuristic,
always setting teacher_review_required=True.
"""
return card