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