Spaces:
Sleeping
Sleeping
| """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 | |
| """ | |
| def model_name(self) -> str: | |
| return "answer_scorer" | |
| def model_version(self) -> str: | |
| return "answer_scorer_v2_baseline_001" | |
| 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 | |