File size: 8,339 Bytes
5af9683
 
 
9312eb7
 
5af9683
 
 
3463404
 
9312eb7
 
dc2c473
5af9683
70b6a97
9312eb7
70b6a97
 
 
9312eb7
 
3463404
70b6a97
 
d019f8f
 
 
 
 
70b6a97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc2c473
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70b6a97
5af9683
 
70b6a97
 
 
5af9683
 
 
70b6a97
 
9312eb7
70b6a97
9312eb7
 
 
 
 
 
 
 
 
 
70b6a97
9312eb7
70b6a97
9312eb7
70b6a97
 
 
 
 
 
9312eb7
 
 
 
3463404
 
 
 
 
 
 
 
9312eb7
 
3463404
 
9312eb7
3463404
9312eb7
 
5af9683
3463404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5af9683
70b6a97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9312eb7
70b6a97
 
 
 
9312eb7
70b6a97
d019f8f
 
 
 
70b6a97
 
 
 
5af9683
 
3463404
70b6a97
 
dc2c473
5af9683
dc2c473
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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
"""
ML Pipeline Wrapper

Loads the fine-tuned mBERT archive from models/mbert_finetuned.keras and uses
it for sentiment predictions.
"""

import os
import json
import shutil
import tempfile
import zipfile
import re

import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer


MODELS_DIR = "models"
CHECKPOINT_NAME = "mbert_finetuned.keras"
EXTRACTED_MODEL_DIR = os.path.join(tempfile.gettempdir(), "aims_nsight_mbert_finetuned")
EXTRACTED_MODEL_MARKER = os.path.join(EXTRACTED_MODEL_DIR, ".archive_signature")
MAX_LENGTH = 256

SENTIMENT_LABELS = {
    0: "Negative",
    1: "Neutral",
    2: "Positive",
}

TOPIC_LABELS = [
    "Course",
    "Internet",
    "Food",
    "Accommodation",
    "Library",
    "Sports",
    "Transportation",
    "Administration",
    "Health",
    "Security",
    "Events",
    "General",
    "Other",
]

TOPIC_KEYWORDS = {
    "Internet": [
        "internet", "wifi", "wi-fi", "network", "connection", "connexion",
        "connectivity", "bandwidth", "router", "online", "signal",
    ],
    "Food": [
        "food", "meal", "meals", "restaurant", "cafeteria", "canteen",
        "dining", "breakfast", "lunch", "dinner", "menu", "repas", "nourriture",
    ],
    "Accommodation": [
        "accommodation", "hostel", "room", "rooms", "dorm", "dormitory",
        "bed", "bathroom", "shower", "housing", "logement", "residence",
    ],
    "Library": [
        "library", "books", "book", "reading", "study room", "librarian",
        "bibliotheque", "bibliothèque",
    ],
    "Sports": [
        "sport", "sports", "football", "basketball", "gym", "fitness",
        "exercise", "training field", "terrain",
    ],
    "Transportation": [
        "transport", "transportation", "bus", "shuttle", "taxi", "car",
        "driver", "pickup", "drop-off", "travel", "commute",
    ],
    "Administration": [
        "administration", "admin", "registration", "registrar", "office",
        "document", "documents", "certificate", "payment", "fees", "finance",
        "visa", "scholarship", "staff",
    ],
    "Health": [
        "health", "clinic", "doctor", "nurse", "medical", "medicine",
        "hospital", "sick", "illness", "mental health", "counseling", "sante", "santé",
    ],
    "Security": [
        "security", "safe", "safety", "guard", "guards", "theft", "stolen",
        "harassment", "danger", "unsafe", "secure",
    ],
    "Events": [
        "event", "events", "seminar", "workshop", "conference", "ceremony",
        "party", "activity", "activities", "orientation",
    ],
    "Course": [
        "course", "courses", "class", "classes", "lecture", "lectures",
        "professor", "teacher", "lecturer", "assignment", "assignments",
        "exam", "exams", "grade", "grades", "curriculum", "module", "modules",
        "academic", "math", "mathematics", "statistics", "python", "project",
        "homework", "cours", "professeur",
    ],
}


class MLModel:
    def __init__(self):
        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        self.model = None
        self.tokenizer = None
        self._load()

    def _load(self):
        path = os.path.join(MODELS_DIR, CHECKPOINT_NAME)
        if not os.path.exists(path):
            raise FileNotFoundError(f"Model archive not found: {path}")

        self._extract_model(path)
        self.tokenizer = AutoTokenizer.from_pretrained(
            EXTRACTED_MODEL_DIR,
            local_files_only=True,
            use_fast=True,
        )
        self.model = AutoModelForSequenceClassification.from_pretrained(
            EXTRACTED_MODEL_DIR,
            local_files_only=True,
        )

        if self.model.config.num_labels != len(SENTIMENT_LABELS):
            raise ValueError(
                f"Model has {self.model.config.num_labels} sentiment classes, but "
                f"{len(SENTIMENT_LABELS)} labels are configured"
            )

        self.model.to(self.device)
        self.model.eval()

        print(f"[ML] Fine-tuned mBERT model loaded from {path}")

    def _extract_model(self, archive_path):
        expected_files = ("config.json", "model.safetensors", "tokenizer.json")
        stat = os.stat(archive_path)
        signature = f"{os.path.abspath(archive_path)}:{stat.st_size}:{stat.st_mtime_ns}"

        if (
            all(os.path.exists(os.path.join(EXTRACTED_MODEL_DIR, name)) for name in expected_files)
            and os.path.exists(EXTRACTED_MODEL_MARKER)
            and self._read_archive_signature() == signature
        ):
            return

        if os.path.exists(EXTRACTED_MODEL_DIR):
            shutil.rmtree(EXTRACTED_MODEL_DIR)
        os.makedirs(EXTRACTED_MODEL_DIR, exist_ok=True)

        with zipfile.ZipFile(archive_path) as archive:
            archive.extractall(EXTRACTED_MODEL_DIR)

        self._write_label_config()
        with open(EXTRACTED_MODEL_MARKER, "w", encoding="utf-8") as marker:
            marker.write(signature)

    def _write_label_config(self):
        config_path = os.path.join(EXTRACTED_MODEL_DIR, "config.json")
        with open(config_path, encoding="utf-8") as config_file:
            config = json.load(config_file)

        config["id2label"] = {str(index): label for index, label in SENTIMENT_LABELS.items()}
        config["label2id"] = {label: index for index, label in SENTIMENT_LABELS.items()}

        with open(config_path, "w", encoding="utf-8") as config_file:
            json.dump(config, config_file, indent=2)
            config_file.write("\n")

    def _read_archive_signature(self):
        with open(EXTRACTED_MODEL_MARKER, encoding="utf-8") as marker:
            return marker.read()

    def predict(self, text: str) -> dict:
        if not self.model or not self.tokenizer:
            raise RuntimeError("ML model is not loaded")

        cleaned = (text or "").strip()
        if not cleaned:
            raise ValueError("Text is required")

        encoded = self.tokenizer(
            cleaned,
            return_tensors="pt",
            truncation=True,
            padding=True,
            max_length=MAX_LENGTH,
        )
        encoded = {key: value.to(self.device) for key, value in encoded.items()}

        with torch.no_grad():
            outputs = self.model(
                input_ids=encoded["input_ids"],
                attention_mask=encoded["attention_mask"],
            )

        sentiment_index = int(torch.argmax(outputs.logits, dim=1).item())

        try:
            sentiment_label = SENTIMENT_LABELS[sentiment_index]
        except KeyError as exc:
            raise ValueError(f"Model returned unknown sentiment class: {sentiment_index}") from exc
        sentiment_score = (
            1 if sentiment_label == "Positive"
            else (-1 if sentiment_label == "Negative" else 0)
        )

        return {
            "sentiment_class": sentiment_index,
            "sentiment_label": sentiment_label,
            "sentiment_score": sentiment_score,
            "topic": self._predict_topic(cleaned),
        }

    def _predict_topic(self, text: str) -> str:
        normalized = self._normalize_topic_text(text)
        scores = {}

        for topic, keywords in TOPIC_KEYWORDS.items():
            score = 0
            for keyword in keywords:
                normalized_keyword = self._normalize_topic_text(keyword)
                if " " in normalized_keyword:
                    if normalized_keyword in normalized:
                        score += len(normalized_keyword.split()) + 1
                elif re.search(rf"\b{re.escape(normalized_keyword)}\b", normalized):
                    score += 1

            if score:
                scores[topic] = score

        if not scores:
            return "General"

        return max(scores, key=lambda topic: (scores[topic], -TOPIC_LABELS.index(topic)))

    @staticmethod
    def _normalize_topic_text(text: str) -> str:
        text = text.lower()
        text = text.replace("é", "e").replace("è", "e").replace("ê", "e")
        text = text.replace("à", "a").replace("â", "a")
        text = text.replace("î", "i").replace("ï", "i")
        text = text.replace("ô", "o")
        text = text.replace("ù", "u").replace("û", "u")
        text = re.sub(r"[^a-z0-9\s-]", " ", text)
        return re.sub(r"\s+", " ", text).strip()