Spaces:
Runtime error
Runtime error
| """Model loading, prediction and stored-metrics access for the API. | |
| Only one model is served: whatever is saved as ``artifacts/best_model.joblib``. | |
| """ | |
| import json | |
| from functools import lru_cache | |
| from config.constants import MIN_WORDS_PER_SENTENCE, NEGATION_WORDS | |
| from config.emoji_map import emojis as EMOJI_TO_ARABIC | |
| from config.paths import ARTIFACTS_DIR, DEFAULT_MODEL_PATH | |
| from preprocessing.pipeline import clean_text | |
| from training.model_io import load_model | |
| from utils.chart_style import PLOTS_SUBDIR | |
| #: Written by ``run_pipeline.py`` | |
| METRICS_PATH = ARTIFACTS_DIR / "metrics.json" | |
| #: Where ``--save-plots`` puts the PNGs. | |
| PLOTS_DIR = ARTIFACTS_DIR / PLOTS_SUBDIR | |
| #: Stable keys the web app asks for -> the file names the pipeline writes. | |
| PLOT_FILES = { | |
| "sentiment_distribution_raw": "01_sentiment_distribution_raw.png", | |
| "sentiment_distribution_balanced": "02_sentiment_distribution_balanced.png", | |
| "review_length": "03_review_length.png", | |
| "top_tokens": "04_top_tokens.png", | |
| "model_comparison": "05_model_comparison.png", | |
| "confusion_matrix": "06_confusion_matrix.png", | |
| "per_class_metrics": "07_per_class_metrics.png", | |
| } | |
| class EmptyAfterCleaning(ValueError): | |
| """Raised when cleaning leaves nothing for the model to classify. | |
| Happens when the input has no Arabic content at all - the ``Removing_non_arabic`` step strips it to an empty string. | |
| """ | |
| def get_model(): | |
| """Load the saved pipeline once and reuse it for every request.""" | |
| return load_model(DEFAULT_MODEL_PATH) | |
| def describe_model(): | |
| """Report what the served pipeline actually is.""" | |
| pipeline = get_model() | |
| classifier = pipeline.named_steps["clf"] | |
| vectorizer = pipeline.named_steps["vect"] | |
| return { | |
| "name": type(classifier).__name__, | |
| "classes": [str(label) for label in classifier.classes_], | |
| "vocabulary_size": len(vectorizer.vocabulary_), | |
| "supports_probabilities": hasattr(pipeline, "predict_proba"), | |
| "supports_margins": hasattr(pipeline, "decision_function"), | |
| } | |
| def _class_scores(pipeline, cleaned): | |
| """Per-class scores for one cleaned string, or ``None`` if unavailable. | |
| Two different things can come back, and the caller must not conflate them: | |
| ``probabilities`` | |
| Real calibrated probabilities summing to 1, from ``predict_proba``. | |
| Available for MultinomialNB / LogisticRegression / RandomForest. | |
| ``margins`` | |
| Signed distances from ``decision_function``. ``SVC`` has these but no | |
| probabilities. They rank the classes but are *not* percentages, so the | |
| UI labels them as margins and never renders them as a confidence. | |
| """ | |
| if hasattr(pipeline, "predict_proba"): | |
| scores = pipeline.predict_proba([cleaned])[0] | |
| return "probabilities", { | |
| str(label): float(score) | |
| for label, score in zip(pipeline.classes_, scores) | |
| } | |
| if hasattr(pipeline, "decision_function"): | |
| scores = pipeline.decision_function([cleaned])[0] | |
| return "margins", { | |
| str(label): float(score) | |
| for label, score in zip(pipeline.classes_, scores) | |
| } | |
| return None, None | |
| def _find_negations(text): | |
| """Negation particles present in the raw input. | |
| ``KEEP_NEGATIONS`` is on, so these survive stopword removal - which is the | |
| whole point, since dropping them would turn "لا احب" into "احب". | |
| """ | |
| tokens = set(text.split()) | |
| return sorted(tokens & NEGATION_WORDS) | |
| def _find_emojis(text): | |
| """Emoji in the raw input paired with the Arabic word they are replaced by.""" | |
| found = [] | |
| seen = set() | |
| for character in text: | |
| if character in EMOJI_TO_ARABIC and character not in seen: | |
| seen.add(character) | |
| found.append({"emoji": character, "arabic": EMOJI_TO_ARABIC[character]}) | |
| return found | |
| def analyze(text, convert_emojis=True): | |
| """Clean ``text``, classify it, and report everything worth showing. | |
| Args: | |
| text: raw user input. | |
| convert_emojis: run the emoji substitution steps first. ``True`` matches how the training corpus was built. | |
| """ | |
| pipeline = get_model() | |
| original = text.strip() | |
| cleaned = clean_text(original, convert_emojis=convert_emojis).strip() | |
| if not cleaned: | |
| raise EmptyAfterCleaning( | |
| "Nothing left after cleaning - the input has no Arabic content." | |
| ) | |
| label = str(pipeline.predict([cleaned])[0]) | |
| score_kind, scores = _class_scores(pipeline, cleaned) | |
| word_count = len(original.split()) | |
| cleaned_word_count = len(cleaned.split()) | |
| notes = [] | |
| if word_count < MIN_WORDS_PER_SENTENCE: | |
| notes.append( | |
| f"Only {word_count} word(s). Reviews shorter than " | |
| f"{MIN_WORDS_PER_SENTENCE} words were dropped from the training " | |
| f"corpus, so this is outside what the model learned from." | |
| ) | |
| if cleaned_word_count < MIN_WORDS_PER_SENTENCE <= word_count: | |
| notes.append( | |
| f"Cleaning reduced this to {cleaned_word_count} word(s). Most of the " | |
| f"input was stopwords, punctuation or non-Arabic characters." | |
| ) | |
| return { | |
| "label": label, | |
| "model": type(pipeline.named_steps["clf"]).__name__, | |
| "original_text": original, | |
| "cleaned_text": cleaned, | |
| "word_count": word_count, | |
| "cleaned_word_count": cleaned_word_count, | |
| "score_kind": score_kind, | |
| "scores": scores, | |
| "negations_found": _find_negations(original), | |
| "emojis_found": _find_emojis(original), | |
| "notes": notes, | |
| } | |
| def load_metrics(): | |
| """Read ``artifacts/metrics.json``, or return an unavailable placeholder. | |
| The file is written by ``run_pipeline.py``. | |
| """ | |
| available_plots = { | |
| key: name | |
| for key, name in PLOT_FILES.items() | |
| if (PLOTS_DIR / name).is_file() | |
| } | |
| if not METRICS_PATH.is_file(): | |
| return { | |
| "available": False, | |
| "best_model": None, | |
| "selected_by": None, | |
| "generated_at": None, | |
| "dataset": None, | |
| "models": [], | |
| "plots": available_plots, | |
| } | |
| stored = json.loads(METRICS_PATH.read_text(encoding="utf-8")) | |
| stored["available"] = True | |
| stored.setdefault("plots", {}) | |
| # Trust the filesystem over the manifest: charts can be deleted or regenerated without rerunning training. | |
| stored["plots"] = available_plots or stored["plots"] | |
| return stored | |
| def plot_path(name): | |
| """Absolute path of a chart PNG, or ``None`` if it is not a known chart. | |
| Only names listed in :data:`PLOT_FILES` resolve, so a request cannot walk | |
| out of the plots directory. | |
| """ | |
| if name not in PLOT_FILES.values(): | |
| return None | |
| candidate = PLOTS_DIR / name | |
| return candidate if candidate.is_file() else None | |