BHM03 commited on
Commit
d40eb53
·
0 Parent(s):

Initial AMANE API deploy — FastAPI + Docker

Browse files
Files changed (5) hide show
  1. Dockerfile +18 -0
  2. app.py +58 -0
  3. predictor.py +427 -0
  4. requirements.txt +12 -0
  5. utils.py +38 -0
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+ ENV PATH="/home/user/.local/bin:$PATH"
8
+ ENV HF_HOME="/home/user/.cache/huggingface"
9
+
10
+ COPY --chown=user requirements.txt .
11
+ RUN pip install --no-cache-dir --upgrade pip && \
12
+ pip install --no-cache-dir -r requirements.txt
13
+
14
+ COPY --chown=user . .
15
+
16
+ EXPOSE 7860
17
+
18
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
app.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import asynccontextmanager
2
+
3
+ from fastapi import FastAPI, HTTPException
4
+ from pydantic import BaseModel
5
+
6
+ from predictor import load_benchmark, load_teacher, load_student
7
+
8
+ _models: dict = {}
9
+
10
+
11
+ @asynccontextmanager
12
+ async def lifespan(app: FastAPI):
13
+ _models["benchmark"] = load_benchmark()
14
+ _models["teacher"] = load_teacher()
15
+ _models["student"] = load_student()
16
+ yield
17
+ _models.clear()
18
+
19
+
20
+ app = FastAPI(title="AMANE Toxicity API", version="1.0.0", lifespan=lifespan)
21
+
22
+
23
+ class PredictRequest(BaseModel):
24
+ text: str
25
+ model: str = "student"
26
+
27
+
28
+ class BatchRequest(BaseModel):
29
+ texts: list[str]
30
+ model: str = "student"
31
+
32
+
33
+ @app.get("/health")
34
+ def health():
35
+ return {
36
+ name: {"loaded": p.loaded, "error": p.error}
37
+ for name, p in _models.items()
38
+ }
39
+
40
+
41
+ @app.post("/predict")
42
+ def predict(req: PredictRequest):
43
+ p = _models.get(req.model)
44
+ if p is None:
45
+ raise HTTPException(status_code=404, detail=f"Unknown model: {req.model}")
46
+ if not p.loaded:
47
+ raise HTTPException(status_code=503, detail=f"Model not available: {p.error}")
48
+ return p.predict(req.text)
49
+
50
+
51
+ @app.post("/predict_batch")
52
+ def predict_batch(req: BatchRequest):
53
+ p = _models.get(req.model)
54
+ if p is None:
55
+ raise HTTPException(status_code=404, detail=f"Unknown model: {req.model}")
56
+ if not p.loaded:
57
+ raise HTTPException(status_code=503, detail=f"Model not available: {p.error}")
58
+ return p.predict_batch(req.texts)
predictor.py ADDED
@@ -0,0 +1,427 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import joblib
4
+ import numpy as np
5
+ from utils import clean_text, LABEL_COLS
6
+
7
+ # torch et transformers sont optionnels — uniquement requis pour le Teacher XLM-R
8
+ try:
9
+ import torch
10
+ import torch.nn as nn
11
+ from transformers import AutoTokenizer, AutoModel
12
+ TORCH_AVAILABLE = True
13
+ except ImportError:
14
+ TORCH_AVAILABLE = False
15
+
16
+
17
+ # ── HuggingFace Hub — fallback si fichiers absents en local ─────────────────
18
+ HF_REPO_ID = 'BHM03/amane-models'
19
+
20
+ def _resolve(local_path: str, hf_filename: str) -> str:
21
+ """Retourne le chemin local si le fichier existe, sinon télécharge depuis HF Hub."""
22
+ if os.path.exists(local_path):
23
+ return local_path
24
+ try:
25
+ from huggingface_hub import hf_hub_download
26
+ return hf_hub_download(
27
+ repo_id=HF_REPO_ID,
28
+ filename=hf_filename,
29
+ token=os.environ.get('HF_TOKEN'),
30
+ )
31
+ except Exception as e:
32
+ raise FileNotFoundError(
33
+ f'Fichier introuvable localement ({local_path}) '
34
+ f'et téléchargement HF Hub échoué : {e}'
35
+ )
36
+
37
+
38
+ # ── Chemins modèles ──────────────────────────────────────────────────────────
39
+ BENCHMARK_MODEL_PATH = 'models/ovr_balanced.joblib'
40
+ BENCHMARK_TFIDF_PATH = 'models/tfidf_vectorizer.joblib'
41
+ TEACHER_CKPT_PATH = 'models/teacher/teacher_best.pt'
42
+ TEACHER_TOKENIZER_PATH = 'models/teacher/tokenizer'
43
+ TEACHER_THRESH_PATH = 'models/teacher/thresholds.json'
44
+ TEACHER_STAR_CKPT_PATH = 'models/teacher_star/teacher_star.pt'
45
+ TEACHER_STAR_THRESH_PATH= 'models/teacher_star/threshold_darija.json'
46
+ STUDENT_CKPT_PATH = 'models/student/student_best.pt'
47
+ STUDENT_THRESH_PATH = 'models/student/threshold_student.json'
48
+
49
+ # ── Hyperparamètres d'inférence Teacher (cohérents avec l'entraînement) ─────
50
+ TEACHER_MAX_LEN = 256
51
+ TEACHER_STAR_MAX_LEN = 128
52
+ STUDENT_MAX_LEN = 128
53
+ TEACHER_MODEL_HF = 'xlm-roberta-base'
54
+ STUDENT_MODEL_HF = 'distilbert-base-multilingual-cased'
55
+
56
+
57
+ # ════════════════════════════════════════════════════════════════════════════
58
+ # BENCHMARK — OneVsRest + LogisticRegression
59
+ # ════════════════════════════════════════════════════════════════════════════
60
+ class BenchmarkPredictor:
61
+ def __init__(self):
62
+ self.model = None
63
+ self.tfidf = None
64
+ self.loaded = False
65
+ self.error = None
66
+
67
+ def load(self):
68
+ try:
69
+ self.model = joblib.load(_resolve(BENCHMARK_MODEL_PATH, 'ovr_balanced.joblib'))
70
+ self.tfidf = joblib.load(_resolve(BENCHMARK_TFIDF_PATH, 'tfidf_vectorizer.joblib'))
71
+ self.loaded = True
72
+ except Exception as e:
73
+ self.error = str(e)
74
+ self.loaded = False
75
+
76
+ def predict(self, text: str) -> dict:
77
+ if not self.loaded:
78
+ raise RuntimeError(f'Benchmark non chargé : {self.error}')
79
+ cleaned = clean_text(text).lower()
80
+ vec = self.tfidf.transform([cleaned])
81
+ probs = self.model.predict_proba(vec)[0]
82
+ return {col: float(round(p, 4)) for col, p in zip(LABEL_COLS, probs)}
83
+
84
+ def predict_batch(self, texts: list) -> list[dict]:
85
+ if not self.loaded:
86
+ raise RuntimeError(f'Benchmark non chargé : {self.error}')
87
+ cleaned = [clean_text(t).lower() for t in texts]
88
+ vecs = self.tfidf.transform(cleaned)
89
+ probs = self.model.predict_proba(vecs)
90
+ return [
91
+ {col: float(round(p, 4)) for col, p in zip(LABEL_COLS, row)}
92
+ for row in probs
93
+ ]
94
+
95
+
96
+ # ════════════════════════════════════════════════════════════════════════════
97
+ # XLM-R TEACHER
98
+ # ════════════════════════════════════════════════════════════════════════════
99
+ class TeacherPredictor:
100
+ def __init__(self):
101
+ self.model = None
102
+ self.tokenizer = None
103
+ self.thresholds = None
104
+ self.loaded = False
105
+ self.error = None
106
+ self.device = None
107
+
108
+ def load(self):
109
+ if not TORCH_AVAILABLE:
110
+ self.error = (
111
+ 'PyTorch / Transformers non installés. '
112
+ 'Lance : pip install torch transformers sentencepiece'
113
+ )
114
+ self.loaded = False
115
+ return
116
+
117
+ try:
118
+ self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
119
+
120
+ # 2. Charger le tokenizer — on utilise directement 'xlm-roberta-base'
121
+ # car le tokenizer ne change pas lors du fine-tuning, et les fichiers
122
+ # locaux sont incomplets (sentencepiece.bpe.model manquant).
123
+ self.tokenizer = AutoTokenizer.from_pretrained(TEACHER_MODEL_HF)
124
+
125
+ # 3. Reconstruire l'architecture (identique au notebook d'entraînement)
126
+ class TeacherModel(nn.Module):
127
+ def __init__(self, model_name: str, num_labels: int = 6, dropout: float = 0.1):
128
+ super().__init__()
129
+ self.encoder = AutoModel.from_pretrained(model_name)
130
+ hidden_size = self.encoder.config.hidden_size
131
+ self.dropout = nn.Dropout(dropout)
132
+ self.classifier = nn.Linear(hidden_size, num_labels)
133
+
134
+ def mean_pool(self, token_embeds, attention_mask):
135
+ mask = attention_mask.unsqueeze(-1).expand(token_embeds.size()).float()
136
+ summed = (token_embeds * mask).sum(dim=1)
137
+ counts = mask.sum(dim=1).clamp(min=1e-9)
138
+ return summed / counts
139
+
140
+ def forward(self, input_ids, attention_mask):
141
+ outputs = self.encoder(input_ids=input_ids, attention_mask=attention_mask)
142
+ pooled = self.mean_pool(outputs.last_hidden_state, attention_mask)
143
+ pooled = self.dropout(pooled)
144
+ return self.classifier(pooled)
145
+
146
+ # 4. Charger le checkpoint
147
+ ckpt = torch.load(_resolve(TEACHER_CKPT_PATH, 'teacher_best.pt'), map_location=self.device, weights_only=False)
148
+ model = TeacherModel(TEACHER_MODEL_HF, num_labels=len(LABEL_COLS))
149
+ model.load_state_dict(ckpt['model_state'])
150
+ model.to(self.device)
151
+ model.eval()
152
+ self.model = model
153
+
154
+ # 5. Charger les seuils tunés
155
+ if os.path.exists(TEACHER_THRESH_PATH):
156
+ with open(TEACHER_THRESH_PATH) as f:
157
+ self.thresholds = json.load(f)
158
+ else:
159
+ # Fallback : seuil 0.5 par défaut
160
+ self.thresholds = {col: 0.5 for col in LABEL_COLS}
161
+
162
+ self.loaded = True
163
+
164
+ except Exception as e:
165
+ self.error = f'{type(e).__name__}: {e}'
166
+ self.loaded = False
167
+
168
+ # ── Inférence : probabilités ───────────────────────────────────────────
169
+ def predict(self, text: str) -> dict:
170
+ """Retourne les probabilités sigmoïdes par label."""
171
+ if not self.loaded:
172
+ raise RuntimeError(f'Teacher non chargé : {self.error}')
173
+
174
+ cleaned = clean_text(text)
175
+ encoding = self.tokenizer(
176
+ cleaned,
177
+ max_length=TEACHER_MAX_LEN,
178
+ padding='max_length',
179
+ truncation=True,
180
+ return_tensors='pt',
181
+ )
182
+ input_ids = encoding['input_ids'].to(self.device)
183
+ attn_mask = encoding['attention_mask'].to(self.device)
184
+
185
+ with torch.no_grad():
186
+ logits = self.model(input_ids, attn_mask)
187
+ probs = torch.sigmoid(logits).cpu().numpy()[0]
188
+
189
+ return {col: float(round(p, 4)) for col, p in zip(LABEL_COLS, probs)}
190
+
191
+ # ── Inférence : labels binaires (avec seuils tunés) ────────────────────
192
+ def predict_with_labels(self, text: str) -> dict:
193
+ """
194
+ Retourne probabilités + labels binaires selon les seuils tunés.
195
+ Format : {'probs': {...}, 'labels': {...}, 'thresholds': {...}}
196
+ """
197
+ probs = self.predict(text)
198
+ labels = {col: int(probs[col] >= self.thresholds[col]) for col in LABEL_COLS}
199
+ return {
200
+ 'probs' : probs,
201
+ 'labels' : labels,
202
+ 'thresholds' : self.thresholds,
203
+ }
204
+
205
+ # ── Inférence : batch ──────────────────────────────────────────────────
206
+ def predict_batch(self, texts: list) -> list[dict]:
207
+ """Inférence batch — plus efficace que predict() en boucle."""
208
+ if not self.loaded:
209
+ raise RuntimeError(f'Teacher non chargé : {self.error}')
210
+
211
+ cleaned = [clean_text(t) for t in texts]
212
+ encoding = self.tokenizer(
213
+ cleaned,
214
+ max_length=TEACHER_MAX_LEN,
215
+ padding='max_length',
216
+ truncation=True,
217
+ return_tensors='pt',
218
+ )
219
+ input_ids = encoding['input_ids'].to(self.device)
220
+ attn_mask = encoding['attention_mask'].to(self.device)
221
+
222
+ with torch.no_grad():
223
+ logits = self.model(input_ids, attn_mask)
224
+ probs = torch.sigmoid(logits).cpu().numpy()
225
+
226
+ return [
227
+ {col: float(round(p, 4)) for col, p in zip(LABEL_COLS, row)}
228
+ for row in probs
229
+ ]
230
+
231
+
232
+ # ════════════════════════════════════════════════════════════════════════════
233
+ # XLM-R TEACHER* — adapté Darija (fine-tuné sur DarLoad, loss masquée)
234
+ # ════════════════════════════════════════════════════════════════════════════
235
+ class TeacherStarPredictor:
236
+ def __init__(self):
237
+ self.model = None
238
+ self.tokenizer = None
239
+ self.threshold = 0.45 # seuil tunée sur DarLoad
240
+ self.loaded = False
241
+ self.error = None
242
+ self.device = None
243
+
244
+ def load(self):
245
+ if not TORCH_AVAILABLE:
246
+ self.error = 'PyTorch / Transformers non installés.'
247
+ self.loaded = False
248
+ return
249
+
250
+ try:
251
+ self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
252
+ self.tokenizer = AutoTokenizer.from_pretrained(TEACHER_MODEL_HF)
253
+
254
+ class TeacherModel(nn.Module):
255
+ def __init__(self, model_name, num_labels=6, dropout=0.1):
256
+ super().__init__()
257
+ self.encoder = AutoModel.from_pretrained(model_name)
258
+ self.dropout = nn.Dropout(dropout)
259
+ self.classifier = nn.Linear(self.encoder.config.hidden_size, num_labels)
260
+
261
+ def mean_pool(self, token_embeds, attention_mask):
262
+ mask = attention_mask.unsqueeze(-1).expand(token_embeds.size()).float()
263
+ summed = (token_embeds * mask).sum(dim=1)
264
+ return summed / mask.sum(dim=1).clamp(min=1e-9)
265
+
266
+ def forward(self, input_ids, attention_mask):
267
+ out = self.encoder(input_ids=input_ids, attention_mask=attention_mask)
268
+ pooled = self.mean_pool(out.last_hidden_state, attention_mask)
269
+ return self.classifier(self.dropout(pooled))
270
+
271
+ ckpt = torch.load(_resolve(TEACHER_STAR_CKPT_PATH, 'teacher_star.pt'), map_location=self.device, weights_only=False)
272
+ model = TeacherModel(TEACHER_MODEL_HF, num_labels=len(LABEL_COLS))
273
+ model.load_state_dict(ckpt['model_state'])
274
+ model.to(self.device)
275
+ model.eval()
276
+ self.model = model
277
+
278
+ if os.path.exists(TEACHER_STAR_THRESH_PATH):
279
+ with open(TEACHER_STAR_THRESH_PATH) as f:
280
+ self.threshold = json.load(f).get('toxic_darija', 0.45)
281
+
282
+ self.loaded = True
283
+
284
+ except Exception as e:
285
+ self.error = f'{type(e).__name__}: {e}'
286
+ self.loaded = False
287
+
288
+ def predict(self, text: str) -> dict:
289
+ if not self.loaded:
290
+ raise RuntimeError(f'Teacher* non chargé : {self.error}')
291
+ cleaned = clean_text(text)
292
+ encoding = self.tokenizer(
293
+ cleaned, max_length=TEACHER_STAR_MAX_LEN,
294
+ padding='max_length', truncation=True, return_tensors='pt',
295
+ )
296
+ with torch.no_grad():
297
+ logits = self.model(
298
+ encoding['input_ids'].to(self.device),
299
+ encoding['attention_mask'].to(self.device),
300
+ )
301
+ probs = torch.sigmoid(logits).cpu().numpy()[0]
302
+ return {col: float(round(p, 4)) for col, p in zip(LABEL_COLS, probs)}
303
+
304
+ def predict_batch(self, texts: list) -> list[dict]:
305
+ if not self.loaded:
306
+ raise RuntimeError(f'Teacher* non chargé : {self.error}')
307
+ cleaned = [clean_text(t) for t in texts]
308
+ encoding = self.tokenizer(
309
+ cleaned, max_length=TEACHER_STAR_MAX_LEN,
310
+ padding='max_length', truncation=True, return_tensors='pt',
311
+ )
312
+ with torch.no_grad():
313
+ logits = self.model(
314
+ encoding['input_ids'].to(self.device),
315
+ encoding['attention_mask'].to(self.device),
316
+ )
317
+ probs = torch.sigmoid(logits).cpu().numpy()
318
+ return [{col: float(round(p, 4)) for col, p in zip(LABEL_COLS, row)} for row in probs]
319
+
320
+
321
+ # ════════════════════════════════════════════════════════════════════════════
322
+ # FACTORY — chargement unique via st.cache_resource
323
+ # ════════════════════════════════════════════════════════════════════════════
324
+ def load_benchmark() -> BenchmarkPredictor:
325
+ p = BenchmarkPredictor()
326
+ p.load()
327
+ return p
328
+
329
+ def load_teacher() -> TeacherPredictor:
330
+ p = TeacherPredictor()
331
+ p.load()
332
+ return p
333
+
334
+ def load_teacher_star() -> TeacherStarPredictor:
335
+ p = TeacherStarPredictor()
336
+ p.load()
337
+ return p
338
+
339
+
340
+ # ════════════════════════════════════════════════════════════════════════════
341
+ # STUDENT — DistilBERT multilingue spécialisé Darija
342
+ # ════════════════════════════════════════════════════════════════════════════
343
+ class StudentPredictor:
344
+ def __init__(self):
345
+ self.model = None
346
+ self.tokenizer = None
347
+ self.threshold = 0.6
348
+ self.loaded = False
349
+ self.error = None
350
+ self.device = None
351
+
352
+ def load(self):
353
+ if not TORCH_AVAILABLE:
354
+ self.error = 'PyTorch / Transformers non installés.'
355
+ self.loaded = False
356
+ return
357
+
358
+ try:
359
+ self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
360
+ self.tokenizer = AutoTokenizer.from_pretrained(STUDENT_MODEL_HF)
361
+
362
+ class StudentModel(nn.Module):
363
+ def __init__(self, model_name, num_labels=6, dropout=0.1):
364
+ super().__init__()
365
+ self.encoder = AutoModel.from_pretrained(model_name)
366
+ self.dropout = nn.Dropout(dropout)
367
+ self.classifier = nn.Linear(self.encoder.config.hidden_size, num_labels)
368
+
369
+ def forward(self, input_ids, attention_mask):
370
+ out = self.encoder(input_ids=input_ids, attention_mask=attention_mask)
371
+ pooled = out.last_hidden_state[:, 0, :] # token [CLS]
372
+ return self.classifier(self.dropout(pooled))
373
+
374
+ ckpt = torch.load(_resolve(STUDENT_CKPT_PATH, 'student_best.pt'), map_location=self.device, weights_only=False)
375
+ model = StudentModel(STUDENT_MODEL_HF, num_labels=len(LABEL_COLS))
376
+ model.load_state_dict(ckpt['model_state'])
377
+ model.to(self.device)
378
+ model.eval()
379
+ self.model = model
380
+
381
+ if os.path.exists(STUDENT_THRESH_PATH):
382
+ with open(STUDENT_THRESH_PATH) as f:
383
+ self.threshold = json.load(f).get('toxic_darija', 0.35)
384
+
385
+ self.loaded = True
386
+
387
+ except Exception as e:
388
+ self.error = f'{type(e).__name__}: {e}'
389
+ self.loaded = False
390
+
391
+ def predict(self, text: str) -> dict:
392
+ if not self.loaded:
393
+ raise RuntimeError(f'Student non chargé : {self.error}')
394
+ cleaned = clean_text(text)
395
+ encoding = self.tokenizer(
396
+ cleaned, max_length=STUDENT_MAX_LEN,
397
+ padding='max_length', truncation=True, return_tensors='pt',
398
+ )
399
+ with torch.no_grad():
400
+ logits = self.model(
401
+ encoding['input_ids'].to(self.device),
402
+ encoding['attention_mask'].to(self.device),
403
+ )
404
+ probs = torch.sigmoid(logits).cpu().numpy()[0]
405
+ return {col: float(round(p, 4)) for col, p in zip(LABEL_COLS, probs)}
406
+
407
+ def predict_batch(self, texts: list) -> list[dict]:
408
+ if not self.loaded:
409
+ raise RuntimeError(f'Student non chargé : {self.error}')
410
+ cleaned = [clean_text(t) for t in texts]
411
+ encoding = self.tokenizer(
412
+ cleaned, max_length=STUDENT_MAX_LEN,
413
+ padding='max_length', truncation=True, return_tensors='pt',
414
+ )
415
+ with torch.no_grad():
416
+ logits = self.model(
417
+ encoding['input_ids'].to(self.device),
418
+ encoding['attention_mask'].to(self.device),
419
+ )
420
+ probs = torch.sigmoid(logits).cpu().numpy()
421
+ return [{col: float(round(p, 4)) for col, p in zip(LABEL_COLS, row)} for row in probs]
422
+
423
+
424
+ def load_student() -> StudentPredictor:
425
+ p = StudentPredictor()
426
+ p.load()
427
+ return p
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.110.0
2
+ uvicorn>=0.27.0
3
+ pydantic>=2.0.0
4
+
5
+ torch>=2.0.0
6
+ transformers>=4.38.0
7
+ sentencepiece>=0.1.99
8
+ huggingface_hub>=0.22.0
9
+
10
+ scikit-learn==1.6.1
11
+ joblib>=1.3.0
12
+ numpy>=1.24.0
utils.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ LABEL_COLS = ['toxic', 'severe_toxic', 'obscene', 'threat', 'insult', 'identity_hate']
4
+
5
+ LABEL_META = {
6
+ 'toxic' : {'fr': 'Toxique', 'color': '#D85A30'},
7
+ 'severe_toxic' : {'fr': 'Très toxique', 'color': '#993C1D'},
8
+ 'obscene' : {'fr': 'Obscène', 'color': '#EF9F27'},
9
+ 'threat' : {'fr': 'Menace', 'color': '#A32D2D'},
10
+ 'insult' : {'fr': 'Insulte', 'color': '#534AB7'},
11
+ 'identity_hate' : {'fr': 'Haine identitaire','color': '#0F6E56'},
12
+ }
13
+
14
+ def clean_text(text: str) -> str:
15
+ if not isinstance(text, str):
16
+ return ''
17
+ text = re.sub(r'[\r\n\t]+', ' ', text)
18
+ text = re.sub(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', '[USER]', text)
19
+ text = re.sub(r'http[s]?://\S+', '[URL]', text)
20
+ text = re.sub(r'==+\s*.+?\s*==+', '', text)
21
+ text = re.sub(r'\{\{.*?\}\}', '', text)
22
+ text = re.sub(r'([!?.])\1{2,}', r'\1\1', text)
23
+ text = re.sub(r' {2,}', ' ', text)
24
+ return text.strip()
25
+
26
+ def toxicity_level(probs: dict) -> tuple[str, str]:
27
+ """Retourne (niveau, couleur) selon les probabilités détectées."""
28
+ max_prob = max(probs.values())
29
+ if probs.get('threat', 0) > 0.5 or probs.get('severe_toxic', 0) > 0.5:
30
+ return 'Très élevé', '#A32D2D'
31
+ elif max_prob > 0.7:
32
+ return 'Élevé', '#D85A30'
33
+ elif max_prob > 0.4:
34
+ return 'Modéré', '#EF9F27'
35
+ elif max_prob > 0.2:
36
+ return 'Faible', '#854F0B'
37
+ else:
38
+ return 'Aucun', '#0F6E56'