mhamza-007 commited on
Commit
d840583
·
verified ·
1 Parent(s): 9f02e25

Upload 56 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. api/__init__.py +1 -0
  2. api/__pycache__/__init__.cpython-312.pyc +0 -0
  3. api/__pycache__/main.cpython-312.pyc +0 -0
  4. api/__pycache__/service.cpython-312.pyc +0 -0
  5. api/main.py +103 -0
  6. api/service.py +199 -0
  7. config/__init__.py +46 -0
  8. config/__pycache__/__init__.cpython-312.pyc +0 -0
  9. config/__pycache__/constants.cpython-312.pyc +0 -0
  10. config/__pycache__/emoji_map.cpython-312.pyc +0 -0
  11. config/__pycache__/paths.cpython-312.pyc +0 -0
  12. config/__pycache__/stopwords.cpython-312.pyc +0 -0
  13. config/constants.py +37 -0
  14. config/emoji_map.py +288 -0
  15. config/paths.py +26 -0
  16. config/stopwords.py +29 -0
  17. inference/__init__.py +5 -0
  18. inference/predict.py +45 -0
  19. models/__init__.py +17 -0
  20. models/__pycache__/__init__.cpython-312.pyc +0 -0
  21. models/__pycache__/registry.cpython-312.pyc +0 -0
  22. models/logistic_regression/__init__.py +5 -0
  23. models/logistic_regression/__pycache__/__init__.cpython-312.pyc +0 -0
  24. models/logistic_regression/__pycache__/model.cpython-312.pyc +0 -0
  25. models/logistic_regression/model.py +10 -0
  26. models/naive_bayes/__init__.py +5 -0
  27. models/naive_bayes/__pycache__/__init__.cpython-312.pyc +0 -0
  28. models/naive_bayes/__pycache__/model.cpython-312.pyc +0 -0
  29. models/naive_bayes/model.py +10 -0
  30. models/random_forest/__init__.py +5 -0
  31. models/random_forest/__pycache__/__init__.cpython-312.pyc +0 -0
  32. models/random_forest/__pycache__/model.cpython-312.pyc +0 -0
  33. models/random_forest/model.py +10 -0
  34. models/registry.py +10 -0
  35. models/svm/__init__.py +5 -0
  36. models/svm/__pycache__/__init__.cpython-312.pyc +0 -0
  37. models/svm/__pycache__/model.cpython-312.pyc +0 -0
  38. models/svm/model.py +10 -0
  39. preprocessing/__init__.py +50 -0
  40. preprocessing/__pycache__/__init__.cpython-312.pyc +0 -0
  41. preprocessing/__pycache__/arabic_normalizer.cpython-312.pyc +0 -0
  42. preprocessing/__pycache__/emoji_handler.cpython-312.pyc +0 -0
  43. preprocessing/__pycache__/pipeline.cpython-312.pyc +0 -0
  44. preprocessing/__pycache__/text_cleaning.cpython-312.pyc +0 -0
  45. preprocessing/arabic_normalizer.py +51 -0
  46. preprocessing/emoji_handler.py +67 -0
  47. preprocessing/pipeline.py +67 -0
  48. preprocessing/text_cleaning.py +66 -0
  49. utils/__init__.py +22 -0
  50. utils/__pycache__/__init__.cpython-312.pyc +0 -0
api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """HTTP layer that serves the saved model to the web app."""
api/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (226 Bytes). View file
 
api/__pycache__/main.cpython-312.pyc ADDED
Binary file (4.24 kB). View file
 
api/__pycache__/service.cpython-312.pyc ADDED
Binary file (8.46 kB). View file
 
api/main.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI app serving the trained model to the web front end.
2
+
3
+ cd ml
4
+ uvicorn api.main:app --reload --port 8000
5
+ """
6
+
7
+ import os
8
+
9
+ from dotenv import load_dotenv
10
+ from fastapi import FastAPI, HTTPException
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from fastapi.responses import FileResponse
13
+ from pydantic import BaseModel, Field
14
+
15
+ from api.service import (
16
+ EmptyAfterCleaning,
17
+ analyze,
18
+ describe_model,
19
+ load_metrics,
20
+ plot_path,
21
+ )
22
+
23
+ load_dotenv()
24
+
25
+
26
+ def allowed_origins():
27
+ """Browser origins permitted to call this API."""
28
+ raw = os.getenv("ASA_CORS_ORIGINS", "http://localhost:3000")
29
+ return [origin.strip() for origin in raw.split(",") if origin.strip()]
30
+
31
+
32
+ app = FastAPI(
33
+ title="Arabic Sentiment Analysis API",
34
+ description="Serves the trained TF-IDF + classifier pipeline.",
35
+ version="1.0.0",
36
+ )
37
+
38
+ app.add_middleware(
39
+ CORSMiddleware,
40
+ allow_origins=allowed_origins(),
41
+ allow_credentials=False,
42
+ allow_methods=["GET", "POST"],
43
+ allow_headers=["*"],
44
+ )
45
+
46
+
47
+ class PredictRequest(BaseModel):
48
+ """One piece of raw Arabic text to classify."""
49
+ text: str = Field(min_length=1, max_length=5000)
50
+ convert_emojis: bool = True
51
+
52
+
53
+ @app.get("/api/health")
54
+ def health():
55
+ try:
56
+ model = describe_model()
57
+ except FileNotFoundError:
58
+ raise HTTPException(
59
+ status_code=503,
60
+ detail="No saved model found. Run `python run_pipeline.py` first.",
61
+ )
62
+ return {"status": "ok", "model": model["name"]}
63
+
64
+
65
+ @app.get("/api/model")
66
+ def model_info():
67
+ """What the served pipeline is, and which score types it can produce."""
68
+ try:
69
+ return describe_model()
70
+ except FileNotFoundError:
71
+ raise HTTPException(
72
+ status_code=503,
73
+ detail="No saved model found. Run `python run_pipeline.py` first.",
74
+ )
75
+
76
+
77
+ @app.get("/api/metrics")
78
+ def metrics():
79
+ """Test-set scores for all four candidate models, plus the chart manifest."""
80
+ return load_metrics()
81
+
82
+
83
+ @app.get("/api/plots/{name}")
84
+ def plot(name: str):
85
+ """Serve one of the pipeline's chart PNGs by file name."""
86
+ path = plot_path(name)
87
+ if path is None:
88
+ raise HTTPException(status_code=404, detail=f"No such chart: {name}")
89
+ return FileResponse(path, media_type="image/png")
90
+
91
+
92
+ @app.post("/api/predict")
93
+ def predict(request: PredictRequest):
94
+ """Clean and classify one piece of text."""
95
+ try:
96
+ return analyze(request.text, convert_emojis=request.convert_emojis)
97
+ except EmptyAfterCleaning as error:
98
+ raise HTTPException(status_code=422, detail=str(error))
99
+ except FileNotFoundError:
100
+ raise HTTPException(
101
+ status_code=503,
102
+ detail="No saved model found. Run `python run_pipeline.py` first.",
103
+ )
api/service.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model loading, prediction and stored-metrics access for the API.
2
+ Only one model is served: whatever is saved as ``artifacts/best_model.joblib``.
3
+ """
4
+
5
+ import json
6
+ from functools import lru_cache
7
+
8
+ from config.constants import MIN_WORDS_PER_SENTENCE, NEGATION_WORDS
9
+ from config.emoji_map import emojis as EMOJI_TO_ARABIC
10
+ from config.paths import ARTIFACTS_DIR, DEFAULT_MODEL_PATH
11
+ from preprocessing.pipeline import clean_text
12
+ from training.model_io import load_model
13
+ from utils.chart_style import PLOTS_SUBDIR
14
+
15
+ #: Written by ``run_pipeline.py``
16
+ METRICS_PATH = ARTIFACTS_DIR / "metrics.json"
17
+
18
+ #: Where ``--save-plots`` puts the PNGs.
19
+ PLOTS_DIR = ARTIFACTS_DIR / PLOTS_SUBDIR
20
+
21
+ #: Stable keys the web app asks for -> the file names the pipeline writes.
22
+ PLOT_FILES = {
23
+ "sentiment_distribution_raw": "01_sentiment_distribution_raw.png",
24
+ "sentiment_distribution_balanced": "02_sentiment_distribution_balanced.png",
25
+ "review_length": "03_review_length.png",
26
+ "top_tokens": "04_top_tokens.png",
27
+ "model_comparison": "05_model_comparison.png",
28
+ "confusion_matrix": "06_confusion_matrix.png",
29
+ "per_class_metrics": "07_per_class_metrics.png",
30
+ }
31
+
32
+
33
+ class EmptyAfterCleaning(ValueError):
34
+ """Raised when cleaning leaves nothing for the model to classify.
35
+ Happens when the input has no Arabic content at all - the ``Removing_non_arabic`` step strips it to an empty string.
36
+ """
37
+
38
+
39
+ @lru_cache(maxsize=1)
40
+ def get_model():
41
+ """Load the saved pipeline once and reuse it for every request."""
42
+ return load_model(DEFAULT_MODEL_PATH)
43
+
44
+
45
+ def describe_model():
46
+ """Report what the served pipeline actually is."""
47
+ pipeline = get_model()
48
+ classifier = pipeline.named_steps["clf"]
49
+ vectorizer = pipeline.named_steps["vect"]
50
+
51
+ return {
52
+ "name": type(classifier).__name__,
53
+ "classes": [str(label) for label in classifier.classes_],
54
+ "vocabulary_size": len(vectorizer.vocabulary_),
55
+ "supports_probabilities": hasattr(pipeline, "predict_proba"),
56
+ "supports_margins": hasattr(pipeline, "decision_function"),
57
+ }
58
+
59
+
60
+ def _class_scores(pipeline, cleaned):
61
+ """Per-class scores for one cleaned string, or ``None`` if unavailable.
62
+
63
+ Two different things can come back, and the caller must not conflate them:
64
+
65
+ ``probabilities``
66
+ Real calibrated probabilities summing to 1, from ``predict_proba``.
67
+ Available for MultinomialNB / LogisticRegression / RandomForest.
68
+
69
+ ``margins``
70
+ Signed distances from ``decision_function``. ``SVC`` has these but no
71
+ probabilities. They rank the classes but are *not* percentages, so the
72
+ UI labels them as margins and never renders them as a confidence.
73
+ """
74
+ if hasattr(pipeline, "predict_proba"):
75
+ scores = pipeline.predict_proba([cleaned])[0]
76
+ return "probabilities", {
77
+ str(label): float(score)
78
+ for label, score in zip(pipeline.classes_, scores)
79
+ }
80
+
81
+ if hasattr(pipeline, "decision_function"):
82
+ scores = pipeline.decision_function([cleaned])[0]
83
+ return "margins", {
84
+ str(label): float(score)
85
+ for label, score in zip(pipeline.classes_, scores)
86
+ }
87
+
88
+ return None, None
89
+
90
+
91
+ def _find_negations(text):
92
+ """Negation particles present in the raw input.
93
+
94
+ ``KEEP_NEGATIONS`` is on, so these survive stopword removal - which is the
95
+ whole point, since dropping them would turn "لا احب" into "احب".
96
+ """
97
+ tokens = set(text.split())
98
+ return sorted(tokens & NEGATION_WORDS)
99
+
100
+
101
+ def _find_emojis(text):
102
+ """Emoji in the raw input paired with the Arabic word they are replaced by."""
103
+ found = []
104
+ seen = set()
105
+ for character in text:
106
+ if character in EMOJI_TO_ARABIC and character not in seen:
107
+ seen.add(character)
108
+ found.append({"emoji": character, "arabic": EMOJI_TO_ARABIC[character]})
109
+ return found
110
+
111
+
112
+ def analyze(text, convert_emojis=True):
113
+ """Clean ``text``, classify it, and report everything worth showing.
114
+
115
+ Args:
116
+ text: raw user input.
117
+ convert_emojis: run the emoji substitution steps first. ``True`` matches how the training corpus was built.
118
+ """
119
+ pipeline = get_model()
120
+ original = text.strip()
121
+ cleaned = clean_text(original, convert_emojis=convert_emojis).strip()
122
+
123
+ if not cleaned:
124
+ raise EmptyAfterCleaning(
125
+ "Nothing left after cleaning - the input has no Arabic content."
126
+ )
127
+
128
+ label = str(pipeline.predict([cleaned])[0])
129
+ score_kind, scores = _class_scores(pipeline, cleaned)
130
+
131
+ word_count = len(original.split())
132
+ cleaned_word_count = len(cleaned.split())
133
+
134
+ notes = []
135
+ if word_count < MIN_WORDS_PER_SENTENCE:
136
+ notes.append(
137
+ f"Only {word_count} word(s). Reviews shorter than "
138
+ f"{MIN_WORDS_PER_SENTENCE} words were dropped from the training "
139
+ f"corpus, so this is outside what the model learned from."
140
+ )
141
+ if cleaned_word_count < MIN_WORDS_PER_SENTENCE <= word_count:
142
+ notes.append(
143
+ f"Cleaning reduced this to {cleaned_word_count} word(s). Most of the "
144
+ f"input was stopwords, punctuation or non-Arabic characters."
145
+ )
146
+
147
+ return {
148
+ "label": label,
149
+ "model": type(pipeline.named_steps["clf"]).__name__,
150
+ "original_text": original,
151
+ "cleaned_text": cleaned,
152
+ "word_count": word_count,
153
+ "cleaned_word_count": cleaned_word_count,
154
+ "score_kind": score_kind,
155
+ "scores": scores,
156
+ "negations_found": _find_negations(original),
157
+ "emojis_found": _find_emojis(original),
158
+ "notes": notes,
159
+ }
160
+
161
+
162
+ def load_metrics():
163
+ """Read ``artifacts/metrics.json``, or return an unavailable placeholder.
164
+ The file is written by ``run_pipeline.py``.
165
+ """
166
+ available_plots = {
167
+ key: name
168
+ for key, name in PLOT_FILES.items()
169
+ if (PLOTS_DIR / name).is_file()
170
+ }
171
+
172
+ if not METRICS_PATH.is_file():
173
+ return {
174
+ "available": False,
175
+ "best_model": None,
176
+ "selected_by": None,
177
+ "generated_at": None,
178
+ "dataset": None,
179
+ "models": [],
180
+ "plots": available_plots,
181
+ }
182
+
183
+ stored = json.loads(METRICS_PATH.read_text(encoding="utf-8"))
184
+ stored["available"] = True
185
+ stored.setdefault("plots", {})
186
+ # Trust the filesystem over the manifest: charts can be deleted or regenerated without rerunning training.
187
+ stored["plots"] = available_plots or stored["plots"]
188
+ return stored
189
+
190
+
191
+ def plot_path(name):
192
+ """Absolute path of a chart PNG, or ``None`` if it is not a known chart.
193
+ Only names listed in :data:`PLOT_FILES` resolve, so a request cannot walk
194
+ out of the plots directory.
195
+ """
196
+ if name not in PLOT_FILES.values():
197
+ return None
198
+ candidate = PLOTS_DIR / name
199
+ return candidate if candidate.is_file() else None
config/__init__.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared configuration: paths, constants, emoji maps and stopwords."""
2
+
3
+ from config.constants import (
4
+ EMOJI_COUNT_COLUMN,
5
+ KEEP_NEGATIONS,
6
+ LABEL_COLUMN,
7
+ NEGATION_WORDS,
8
+ RANDOM_STATE,
9
+ RATING_TO_SENTIMENT,
10
+ SENTIMENT_PALETTE,
11
+ TEST_SIZE,
12
+ TEXT_COLUMN,
13
+ )
14
+ from config.emoji_map import emojis, emoticons_to_emoji
15
+ from config.paths import (
16
+ ARTIFACTS_DIR,
17
+ DEFAULT_CLEANED_DATA_PATH,
18
+ DEFAULT_MODEL_PATH,
19
+ DEFAULT_RAW_DATA_PATH,
20
+ DEFAULT_SHARING_DATA_PATH,
21
+ ORIGINAL_DIR,
22
+ PROJECT_ROOT,
23
+ )
24
+ from config.stopwords import get_arabic_stopwords
25
+
26
+ __all__ = [
27
+ "ARTIFACTS_DIR",
28
+ "DEFAULT_CLEANED_DATA_PATH",
29
+ "DEFAULT_MODEL_PATH",
30
+ "DEFAULT_RAW_DATA_PATH",
31
+ "DEFAULT_SHARING_DATA_PATH",
32
+ "EMOJI_COUNT_COLUMN",
33
+ "KEEP_NEGATIONS",
34
+ "LABEL_COLUMN",
35
+ "NEGATION_WORDS",
36
+ "ORIGINAL_DIR",
37
+ "PROJECT_ROOT",
38
+ "RANDOM_STATE",
39
+ "RATING_TO_SENTIMENT",
40
+ "SENTIMENT_PALETTE",
41
+ "TEST_SIZE",
42
+ "TEXT_COLUMN",
43
+ "emojis",
44
+ "emoticons_to_emoji",
45
+ "get_arabic_stopwords",
46
+ ]
config/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (1.02 kB). View file
 
config/__pycache__/constants.cpython-312.pyc ADDED
Binary file (1.01 kB). View file
 
config/__pycache__/emoji_map.cpython-312.pyc ADDED
Binary file (11.4 kB). View file
 
config/__pycache__/paths.cpython-312.pyc ADDED
Binary file (1.23 kB). View file
 
config/__pycache__/stopwords.cpython-312.pyc ADDED
Binary file (1.26 kB). View file
 
config/constants.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ TEXT_COLUMN = "text"
2
+ LABEL_COLUMN = "label"
3
+ EMOJI_COUNT_COLUMN = "emoji_count"
4
+ RAW_COLUMNS_TO_DROP = ["Unnamed: 0", "company"]
5
+ RATING_COLUMN = "rating"
6
+ RAW_TEXT_COLUMN = "Feed"
7
+ RAW_TEXT_COLUMN_CANDIDATES = ("Feed", "review_description")
8
+ SENTIMENT_COLUMN = "Sentiment"
9
+ RATING_TO_SENTIMENT = {1: "Positive", 0: "Neutral", -1: "Negative"}
10
+ SENTIMENT_PALETTE = {"Positive": "green", "Neutral": "gray", "Negative": "red"}
11
+ TEST_SIZE = 0.2
12
+ RANDOM_STATE = 42
13
+ MIN_WORDS_PER_SENTENCE = 3
14
+
15
+ # Arabic negation particles that NLTK lists as stopwords.
16
+ # Removing these destroys polarity - "لا احب" ("I don't like") collapses to "احب" ("I like")
17
+ # They are kept in the text when :data:`KEEP_NEGATIONS` is on.
18
+ NEGATION_WORDS = frozenset({
19
+ "لا", # no / not
20
+ "ما", # not (also the relative "what")
21
+ "لم", # did not
22
+ "لن", # will not
23
+ "غير", # not / other than
24
+ "ليس", # is not
25
+ "ليست", # is not (f.)
26
+ "ليسا", # are not (dual)
27
+ "ليسوا", # are not (pl.)
28
+ "لست", # you/I am not
29
+ "ولا", # and not / nor
30
+ "وما", # and not
31
+ "دون", # without
32
+ "أبدا", # never / at all
33
+ })
34
+
35
+ # Keep the words in :data:`NEGATION_WORDS` instead of filtering them out.
36
+ # Changing this changes the training corpus, so the model must be retrained to match.
37
+ KEEP_NEGATIONS = True
config/emoji_map.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Emoji / emoticon lookup tables.
2
+
3
+ ``emojis`` -- emoji character -> Arabic word(s).
4
+ ``emoticons_to_emoji`` -- ASCII emoticon -> emoji character (applied first).
5
+ """
6
+
7
+ emojis = {
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
+ "👽":"فضائي",
201
+ "☔️":"مطر",
202
+ "🍷":"عصير",
203
+ "🌟":"نجمة",
204
+ "☁️":"سحب",
205
+ "👃":"معترض",
206
+ "🌺":"مرح",
207
+ "🔪":"سكينة",
208
+ "♨":"سخونية",
209
+ "👊🏼":"ضرب",
210
+ "✏":"قلم",
211
+ "🚶🏾‍♀️":"يسير",
212
+ "👊":"ضربة",
213
+ "◾️":"وقف",
214
+ "😚":"حب",
215
+ "🔸":"مرح",
216
+ "👎🏻":"لا يعجبني",
217
+ "👊🏽":"ضربة",
218
+ "😙":"حب",
219
+ "🎥":"تصوير",
220
+ "👉":"جذب انتباه",
221
+ "👏🏽":"يصفق",
222
+ "💪🏻":"عضلات",
223
+ "🏴":"اسود",
224
+ "🔥":"حريق",
225
+ "😬":"عدم الراحة",
226
+ "👊🏿":"يضرب",
227
+ "🌿":"ورقه شجره",
228
+ "✋🏼":"كف ايد",
229
+ "👐":"ايدي مفتوحه",
230
+ "☠️":"وجه مرعب",
231
+ "🎉":"يهنئ",
232
+ "🔕" :"صامت",
233
+ "😿":"وجه حزين",
234
+ "☹️":"وجه يائس",
235
+ "😘" :"حب",
236
+ "😰" :"خوف و حزن",
237
+ "🌼":"ورده",
238
+ "💋": "بوسه",
239
+ "👇":"لاسفل",
240
+ "❣️":"حب",
241
+ "🎧":"سماعات",
242
+ "📝":"يكتب",
243
+ "😇":"دايخ",
244
+ "😈":"رعب",
245
+ "🏃":"يجري",
246
+ "✌🏻":"علامه النصر",
247
+ "🔫":"يضرب",
248
+ "❗️":"تعجب",
249
+ "👎":"غير موافق",
250
+ "🔐":"قفل",
251
+ "👈":"لليمين",
252
+ "™":"رمز",
253
+ "🚶🏽":"يتمشي",
254
+ "😯":"متفاجأ",
255
+ "✊":"يد مغلقه",
256
+ "😻":"اعجاب",
257
+ "🙉" :"قرد",
258
+ "👧":"طفله صغيره",
259
+ "🔴":"دائره حمراء",
260
+ "💪🏽":"قوه",
261
+ "💤":"ينام",
262
+ "👀":"ينظر",
263
+ "✍🏻":"يكتب",
264
+ "❄️":"تلج",
265
+ "💀":"رعب",
266
+ "😤":"وجه عابس",
267
+ "🖋":"قلم",
268
+ "🎩":"كاب",
269
+ "☕️":"قهوه",
270
+ "😹":"ضحك",
271
+ "💓":"حب",
272
+ "☄️ ":"نار",
273
+ "👻":"رعب",
274
+ }
275
+
276
+ emoticons_to_emoji = {
277
+ ":)" : "🙂",
278
+ ":(" : "🙁",
279
+ "xD" : "😆",
280
+ ":=(": "😭",
281
+ ":'(": "😢",
282
+ ":'‑(": "😢",
283
+ "XD" : "😂",
284
+ ":D" : "🙂",
285
+ "♬" : "موسيقي",
286
+ "♡" : "❤",
287
+ "☻" : "🙂",
288
+ }
config/paths.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Filesystem locations used by the pipeline."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
7
+
8
+ ORIGINAL_DIR = PROJECT_ROOT / "original"
9
+
10
+ DEFAULT_RAW_DATA_PATH = Path(
11
+ os.getenv("ASA_RAW_DATA_PATH", ORIGINAL_DIR / "original_dataset.xlsx")
12
+ )
13
+
14
+ ARTIFACTS_DIR = Path(os.getenv("ASA_ARTIFACTS_DIR", PROJECT_ROOT / "artifacts"))
15
+
16
+ DEFAULT_SHARING_DATA_PATH = Path(
17
+ os.getenv("ASA_SHARING_DATA_PATH", ARTIFACTS_DIR / "sharing_arabic.xlsx")
18
+ )
19
+
20
+ DEFAULT_CLEANED_DATA_PATH = Path(
21
+ os.getenv("ASA_CLEANED_DATA_PATH", ARTIFACTS_DIR / "cleaned.xlsx")
22
+ )
23
+
24
+ DEFAULT_MODEL_PATH = Path(
25
+ os.getenv("ASA_MODEL_PATH", ARTIFACTS_DIR / "best_model.joblib")
26
+ )
config/stopwords.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Arabic stopword list used by :func:`preprocessing.text_cleaning.remove_stop_words`."""
2
+
3
+ import nltk
4
+ from nltk.corpus import stopwords
5
+
6
+ from config.constants import KEEP_NEGATIONS, NEGATION_WORDS
7
+
8
+ _cache = {}
9
+
10
+
11
+ def get_arabic_stopwords(keep_negations=None):
12
+ """Return the NLTK Arabic stopword list.
13
+ Downloads the corpus on first use and caches the result per setting.
14
+ """
15
+ if keep_negations is None:
16
+ keep_negations = KEEP_NEGATIONS
17
+
18
+ if keep_negations not in _cache:
19
+ try:
20
+ nltk.data.find("corpora/stopwords")
21
+ except LookupError:
22
+ nltk.download("stopwords")
23
+
24
+ words = stopwords.words("arabic")
25
+ if keep_negations:
26
+ words = [w for w in words if w not in NEGATION_WORDS]
27
+ _cache[keep_negations] = words
28
+
29
+ return _cache[keep_negations]
inference/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Loading a saved model and predicting on new text."""
2
+
3
+ from inference.predict import get_default_model, predict_sentiment
4
+
5
+ __all__ = ["get_default_model", "predict_sentiment"]
inference/predict.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sentiment prediction for new, unseen text."""
2
+
3
+ import pandas as pd
4
+
5
+ from config.constants import TEXT_COLUMN
6
+ from config.paths import DEFAULT_MODEL_PATH
7
+ from preprocessing.pipeline import preprocess_dataframe
8
+ from training.model_io import load_model
9
+
10
+ _default_model = None
11
+
12
+
13
+ def get_default_model(path=DEFAULT_MODEL_PATH):
14
+ """Load (and cache) the saved best model."""
15
+ global _default_model
16
+ if _default_model is None:
17
+ _default_model = load_model(path)
18
+ return _default_model
19
+
20
+
21
+ def predict_sentiment(text, model=None, convert_emojis=True):
22
+ """Predict the sentiment of a single piece of raw text.
23
+
24
+ The text goes through the same cleaning chain the training corpus went
25
+ through - emoticons to emojis, emojis to Arabic words, remove stopwords,
26
+ remove non-Arabic characters, normalize, remove numbers, remove
27
+ hashtags/mentions, remove URLs, remove punctuation, light-stem - and is
28
+ then handed to the fitted TF-IDF + classifier pipeline.
29
+ """
30
+ if model is None:
31
+ model = get_default_model()
32
+
33
+ # Create a DataFrame with the input text
34
+ text = [text]
35
+ daf = pd.DataFrame({TEXT_COLUMN: text})
36
+
37
+ # Preprocess the text
38
+ daf = preprocess_dataframe(daf, text_column=TEXT_COLUMN,
39
+ convert_emojis=convert_emojis,
40
+ drop_small_sentences=False)
41
+
42
+ # Make predictions
43
+ predictions = model.predict(daf[TEXT_COLUMN])
44
+
45
+ return predictions
models/__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One sub-package per classifier, plus the registry that collects them."""
2
+
3
+ from models import logistic_regression, naive_bayes, random_forest, svm
4
+
5
+ __all__ = [
6
+ "build_models_dict",
7
+ "logistic_regression",
8
+ "naive_bayes",
9
+ "random_forest",
10
+ "svm",
11
+ ]
12
+
13
+
14
+ def build_models_dict():
15
+ from models.registry import build_models_dict as _build_models_dict
16
+
17
+ return _build_models_dict()
models/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (608 Bytes). View file
 
models/__pycache__/registry.cpython-312.pyc ADDED
Binary file (752 Bytes). View file
 
models/logistic_regression/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Logistic Regression classifier."""
2
+
3
+ from models.logistic_regression.model import MODEL_NAME, build_model
4
+
5
+ __all__ = ["MODEL_NAME", "build_model"]
models/logistic_regression/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (355 Bytes). View file
 
models/logistic_regression/__pycache__/model.cpython-312.pyc ADDED
Binary file (495 Bytes). View file
 
models/logistic_regression/model.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Logistic Regression configuration."""
2
+
3
+ from sklearn.linear_model import LogisticRegression
4
+
5
+ MODEL_NAME = "LogisticRegression"
6
+
7
+
8
+ def build_model():
9
+ """Return an untrained ``LogisticRegression``."""
10
+ return LogisticRegression()
models/naive_bayes/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Multinomial Naive Bayes classifier."""
2
+
3
+ from models.naive_bayes.model import MODEL_NAME, build_model
4
+
5
+ __all__ = ["MODEL_NAME", "build_model"]
models/naive_bayes/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (343 Bytes). View file
 
models/naive_bayes/__pycache__/model.cpython-312.pyc ADDED
Binary file (479 Bytes). View file
 
models/naive_bayes/model.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multinomial Naive Bayes configuration."""
2
+
3
+ from sklearn.naive_bayes import MultinomialNB
4
+
5
+ MODEL_NAME = "MultinomialNB"
6
+
7
+
8
+ def build_model():
9
+ """Return an untrained ``MultinomialNB``."""
10
+ return MultinomialNB()
models/random_forest/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Random Forest classifier."""
2
+
3
+ from models.random_forest.model import MODEL_NAME, build_model
4
+
5
+ __all__ = ["MODEL_NAME", "build_model"]
models/random_forest/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (337 Bytes). View file
 
models/random_forest/__pycache__/model.cpython-312.pyc ADDED
Binary file (487 Bytes). View file
 
models/random_forest/model.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Random Forest configuration."""
2
+
3
+ from sklearn.ensemble import RandomForestClassifier
4
+
5
+ MODEL_NAME = "RandomForestClassifier"
6
+
7
+
8
+ def build_model():
9
+ """Return an untrained ``RandomForestClassifier``."""
10
+ return RandomForestClassifier()
models/registry.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Registry of the four classifiers compared by the pipeline."""
2
+
3
+ from models import logistic_regression, naive_bayes, random_forest, svm
4
+
5
+ MODEL_MODULES = (naive_bayes, random_forest, logistic_regression, svm)
6
+
7
+
8
+ def build_models_dict():
9
+ """Return ``{model_name: untrained estimator}`` for every registered model."""
10
+ return {module.MODEL_NAME: module.build_model() for module in MODEL_MODULES}
models/svm/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Support Vector classifier."""
2
+
3
+ from models.svm.model import MODEL_NAME, build_model
4
+
5
+ __all__ = ["MODEL_NAME", "build_model"]
models/svm/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (318 Bytes). View file
 
models/svm/__pycache__/model.cpython-312.pyc ADDED
Binary file (444 Bytes). View file
 
models/svm/model.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Support Vector Classifier configuration."""
2
+
3
+ from sklearn.svm import SVC
4
+
5
+ MODEL_NAME = "SVC"
6
+
7
+
8
+ def build_model():
9
+ """Return an untrained ``SVC``."""
10
+ return SVC()
preprocessing/__init__.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Text cleaning / normalization for Arabic sentiment analysis."""
2
+
3
+ from preprocessing.arabic_normalizer import Arabic_Light_Stemmer, normalizeArabic
4
+ from preprocessing.emoji_handler import (
5
+ emoji_counter,
6
+ extract_emoji,
7
+ remove_emoji,
8
+ replace_emojis_with_text,
9
+ replace_emoticon_with_emojis,
10
+ space_between_emojis,
11
+ )
12
+ from preprocessing.pipeline import (
13
+ CLEANING_STEPS,
14
+ EMOJI_STEPS,
15
+ clean_text,
16
+ preprocess_dataframe,
17
+ )
18
+ from preprocessing.text_cleaning import (
19
+ Removing_non_arabic,
20
+ Removing_numbers,
21
+ Removing_punctuations,
22
+ Removing_urls,
23
+ remove_extra_Space,
24
+ remove_hashtags_and_mentions,
25
+ remove_small_sentences,
26
+ remove_stop_words,
27
+ )
28
+
29
+ __all__ = [
30
+ "Arabic_Light_Stemmer",
31
+ "CLEANING_STEPS",
32
+ "EMOJI_STEPS",
33
+ "Removing_non_arabic",
34
+ "Removing_numbers",
35
+ "Removing_punctuations",
36
+ "Removing_urls",
37
+ "clean_text",
38
+ "emoji_counter",
39
+ "extract_emoji",
40
+ "normalizeArabic",
41
+ "preprocess_dataframe",
42
+ "remove_emoji",
43
+ "remove_extra_Space",
44
+ "remove_hashtags_and_mentions",
45
+ "remove_small_sentences",
46
+ "remove_stop_words",
47
+ "replace_emojis_with_text",
48
+ "replace_emoticon_with_emojis",
49
+ "space_between_emojis",
50
+ ]
preprocessing/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (1.14 kB). View file
 
preprocessing/__pycache__/arabic_normalizer.cpython-312.pyc ADDED
Binary file (2.48 kB). View file
 
preprocessing/__pycache__/emoji_handler.cpython-312.pyc ADDED
Binary file (3.55 kB). View file
 
preprocessing/__pycache__/pipeline.cpython-312.pyc ADDED
Binary file (2.06 kB). View file
 
preprocessing/__pycache__/text_cleaning.cpython-312.pyc ADDED
Binary file (3.76 kB). View file
 
preprocessing/arabic_normalizer.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Arabic-specific normalization and light stemming."""
2
+
3
+ import re
4
+ import warnings
5
+
6
+ with warnings.catch_warnings():
7
+ warnings.simplefilter("ignore", SyntaxWarning)
8
+ import pyarabic.araby as araby
9
+ from tashaphyne.stemming import ArabicLightStemmer
10
+
11
+
12
+ def normalizeArabic(text):
13
+ """Unify Arabic letter forms, collapse repetitions and strip diacritics/digits."""
14
+ text = text.strip()
15
+ text = re.sub("ى", "ي", text)
16
+ text = re.sub("ؤ", "ء", text)
17
+ text = re.sub("ئ", "ء", text)
18
+ text = re.sub("ة", "ه", text)
19
+
20
+ #remove repetetions
21
+ text = re.sub("[إأٱآا]", "ا", text)
22
+ text = text.replace('وو', 'و')
23
+ text = text.replace('يي', 'ي')
24
+ text = text.replace('ييي', 'ي')
25
+ text = text.replace('اا', 'ا')
26
+
27
+ ## remove extra whitespace
28
+ text = re.sub(r'\s+', ' ', text)
29
+
30
+ # Remove longation
31
+ text = re.sub(r'(.)\1+', r"\1\1", text)
32
+
33
+ # Strip vowels from a text, include Shadda.
34
+ text = araby.strip_tashkeel(text)
35
+
36
+ # Strip diacritics from a text, include harakats and small lettres The striped marks are
37
+ text = araby.strip_diacritics(text)
38
+ text = ''.join([i for i in text if not i.isdigit()])
39
+ return text
40
+
41
+
42
+ def Arabic_Light_Stemmer(text):
43
+ """Light-stem every word of ``text`` with Tashaphyne's ``ArabicLightStemmer``."""
44
+ # Arabic Light Stemming is a specific type of stemming applied to Arabic words. Stemming is the process
45
+ # of reducing words to their root or base form, which helps in normalizing text for analysis.
46
+ Arabic_Stemmer = ArabicLightStemmer()
47
+
48
+ # stemming each word
49
+ text = [Arabic_Stemmer.light_stem(y) for y in text.split()]
50
+
51
+ return " ".join(text)
preprocessing/emoji_handler.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Emoji and emoticon handling."""
2
+
3
+ import re
4
+
5
+ import emoji
6
+ import regex
7
+
8
+ from config.emoji_map import emojis, emoticons_to_emoji
9
+
10
+
11
+ def extract_emoji(text):
12
+ """Return the list of emoji grapheme clusters contained in ``text``."""
13
+ emoji_list = []
14
+ data = regex.findall(r'\X', text)
15
+ for word in data:
16
+ if any(emoji.distinct_emoji_list(char) for char in word):
17
+ emoji_list.append(word)
18
+
19
+ return emoji_list
20
+
21
+
22
+ def emoji_counter(sentence):
23
+ """Return the number of emojis in ``sentence`` (used for the EDA table)."""
24
+ return emoji.emoji_count(sentence)
25
+
26
+
27
+ def replace_emoticon_with_emojis(message):
28
+ """Replace whitespace-delimited ASCII emoticons with their emoji equivalent."""
29
+ separate_words = message.split(' ')
30
+ modified_message = ""
31
+
32
+ for word in separate_words:
33
+ modified_message += emoticons_to_emoji.get(word, word) + " "
34
+
35
+ return modified_message.strip() # Remove trailing space
36
+
37
+
38
+ def replace_emojis_with_text(message):
39
+ """Replace each known emoji with the Arabic word(s) describing it."""
40
+ separate_words = regex.findall(r'\X', message)
41
+ modified_message = ""
42
+
43
+ for word in separate_words:
44
+ if any(emoji.distinct_emoji_list(char) for char in word):
45
+ modified_message += " " + emojis.get(word, word) + " "
46
+ else:
47
+ modified_message += emojis.get(word, word) + ""
48
+
49
+ return modified_message
50
+
51
+
52
+ def remove_emoji(string):
53
+ """Strip emoji characters from ``string`` (not part of the active pipeline)."""
54
+ emoji_pattern = re.compile("["
55
+ u"\U0001F600-\U0001F64F" # emoticons
56
+ u"\U0001F300-\U0001F5FF" # symbols & pictographs
57
+ u"\U0001F680-\U0001F6FF" # transport & map symbols
58
+ u"\U0001F1E0-\U0001F1FF" # flags (iOS)
59
+ u"\U00002702-\U000027B0"
60
+ u"\U000024C2-\U0001F251"
61
+ "]+", flags=re.UNICODE)
62
+ return emoji_pattern.sub(r'', string).strip()
63
+
64
+
65
+ def space_between_emojis(s):
66
+ """Pad every emoji with spaces (not part of the active pipeline)."""
67
+ return ''.join((' '+c+' ') if c in emoji.UNICODE_EMOJI['en'] else c for c in s)
preprocessing/pipeline.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The single preprocessing pipeline shared by training and inference.
2
+
3
+ Order of the steps:
4
+ 1. ``replace_emoticon_with_emojis`` (emoji step)
5
+ 2. ``replace_emojis_with_text`` (emoji step)
6
+ 3. ``remove_stop_words``
7
+ 4. ``Removing_non_arabic``
8
+ 5. ``normalizeArabic``
9
+ 6. ``Removing_numbers``
10
+ 7. ``remove_hashtags_and_mentions``
11
+ 8. ``Removing_urls``
12
+ 9. ``Removing_punctuations``
13
+ 10. ``Arabic_Light_Stemmer``
14
+ """
15
+
16
+ from config.constants import TEXT_COLUMN
17
+ from preprocessing.arabic_normalizer import Arabic_Light_Stemmer, normalizeArabic
18
+ from preprocessing.emoji_handler import (
19
+ replace_emojis_with_text,
20
+ replace_emoticon_with_emojis,
21
+ )
22
+ from preprocessing.text_cleaning import (
23
+ Removing_non_arabic,
24
+ Removing_numbers,
25
+ Removing_punctuations,
26
+ Removing_urls,
27
+ remove_hashtags_and_mentions,
28
+ remove_small_sentences,
29
+ remove_stop_words,
30
+ )
31
+
32
+ #: Steps 1-2 - emoticon/emoji substitution.
33
+ EMOJI_STEPS = (
34
+ replace_emoticon_with_emojis,
35
+ replace_emojis_with_text,
36
+ )
37
+
38
+ #: Steps 3-10 - applied to both the training corpus and inference input.
39
+ CLEANING_STEPS = (
40
+ remove_stop_words,
41
+ Removing_non_arabic,
42
+ normalizeArabic,
43
+ Removing_numbers,
44
+ remove_hashtags_and_mentions,
45
+ Removing_urls,
46
+ Removing_punctuations,
47
+ Arabic_Light_Stemmer,
48
+ )
49
+
50
+
51
+ def clean_text(text, convert_emojis=True):
52
+ """Run the full cleaning chain on a single string."""
53
+ steps = (EMOJI_STEPS + CLEANING_STEPS) if convert_emojis else CLEANING_STEPS
54
+ for step in steps:
55
+ text = step(text)
56
+ return text
57
+
58
+
59
+ def preprocess_dataframe(df, text_column=TEXT_COLUMN, convert_emojis=True,
60
+ drop_small_sentences=True):
61
+ df[text_column] = df[text_column].apply(
62
+ lambda text: clean_text(text, convert_emojis=convert_emojis)
63
+ )
64
+ if drop_small_sentences:
65
+ # this function will convert the text which contains one or two words into null value
66
+ remove_small_sentences(df, text_column=text_column)
67
+ return df
preprocessing/text_cleaning.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generic text cleaning helpers (stopwords, punctuation, URLs, numbers, ...)."""
2
+
3
+ import re
4
+
5
+ import numpy as np
6
+
7
+ from config.constants import MIN_WORDS_PER_SENTENCE, TEXT_COLUMN
8
+ from config.stopwords import get_arabic_stopwords
9
+
10
+
11
+ def remove_stop_words(text):
12
+ """Drop NLTK Arabic stopwords from ``text``."""
13
+ arabic_stopwords = get_arabic_stopwords()
14
+ Text = [i for i in str(text).split() if i not in arabic_stopwords]
15
+ return " ".join(Text)
16
+
17
+
18
+ def Removing_non_arabic(text):
19
+ """Replace latin letter runs with a single space."""
20
+ text = re.sub('[A-Za-z]+', ' ', text)
21
+ return text
22
+
23
+
24
+ def Removing_numbers(text):
25
+ """Drop every digit character."""
26
+ text = ''.join([i for i in text if not i.isdigit()])
27
+ return text
28
+
29
+
30
+ def Removing_punctuations(text):
31
+ """Replace punctuation (latin + Arabic) with spaces and squeeze whitespace."""
32
+ ## Remove punctuations
33
+ text = re.sub('[%s]' % re.escape(r"""!"#$%&'()*+,،-./:;<=>؟?@[\]^_`{|}~"""), ' ', text)
34
+ text = text.replace('؛', "", )
35
+
36
+ ## remove extra whitespace
37
+ text = re.sub(r'\s+', ' ', text)
38
+ text = " ".join(text.split())
39
+ return text.strip()
40
+
41
+
42
+ def Removing_urls(text):
43
+ """Strip http(s):// and www. URLs."""
44
+ url_pattern = re.compile(r'https?://\S+|www\.\S+')
45
+ return url_pattern.sub(r'', text)
46
+
47
+
48
+ def remove_extra_Space(text):
49
+ """Collapse repeated whitespace into single spaces."""
50
+ text = re.sub(r'\s+', ' ', text)
51
+ return " ".join(text.split())
52
+
53
+
54
+ def remove_hashtags_and_mentions(text):
55
+ """Strip ``@mentions`` and ``#hashtags`` written with latin characters."""
56
+ text = re.sub("@[A-Za-z0-9_]+", "", text)
57
+ text = re.sub("#[A-Za-z0-9_]+", "", text)
58
+ return text
59
+
60
+
61
+ def remove_small_sentences(df, text_column=TEXT_COLUMN):
62
+ """Turn texts shorter than 3 words into NaN, in place."""
63
+ text_position = df.columns.get_loc(text_column)
64
+ for i in range(len(df)):
65
+ if len(df[text_column].iloc[i].split()) < MIN_WORDS_PER_SENTENCE:
66
+ df.iloc[i, text_position] = np.nan
utils/__init__.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cross-cutting helpers: charts and exploratory statistics."""
2
+
3
+ from utils.emoji_stats import add_emoji_count, most_common_emojis
4
+ from utils.visualization import (
5
+ plot_confusion_matrix,
6
+ plot_model_comparison,
7
+ plot_per_class_metrics,
8
+ plot_review_length_distribution,
9
+ plot_sentiment_distribution,
10
+ plot_top_tokens,
11
+ )
12
+
13
+ __all__ = [
14
+ "add_emoji_count",
15
+ "most_common_emojis",
16
+ "plot_confusion_matrix",
17
+ "plot_model_comparison",
18
+ "plot_per_class_metrics",
19
+ "plot_review_length_distribution",
20
+ "plot_sentiment_distribution",
21
+ "plot_top_tokens",
22
+ ]
utils/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (630 Bytes). View file