Spaces:
Runtime error
Runtime error
File size: 6,898 Bytes
d840583 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | """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.
"""
@lru_cache(maxsize=1)
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
|