Mirae Kang commited on
Commit
7e2bd98
·
2 Parent(s): ce08923b53eb67

Merge pull request #18 from Bootcamp-IA-P6/v2

Browse files

Add end-to-end NLP toxicity classification pipeline

Files changed (39) hide show
  1. .gitattributes +1 -0
  2. .gitignore +66 -0
  3. configs/best_params.yaml +15 -0
  4. configs/features.yaml +35 -0
  5. configs/models.yaml +34 -0
  6. configs/pipeline.yaml +19 -0
  7. data/processed/v2/comments_with_stats.csv +0 -0
  8. models/.gitkeep +0 -0
  9. models/final_model.joblib +3 -0
  10. notebooks/01_eda_v2.ipynb +0 -0
  11. notebooks/02_preprocessing_v2.ipynb +814 -0
  12. notebooks/03_vectorization_v2.ipynb +0 -0
  13. notebooks/04_baseline_v2.ipynb +0 -0
  14. notebooks/05_ensemble_v2.ipynb +0 -0
  15. notebooks/06_tuning_clean_v2.ipynb +956 -0
  16. notebooks/07_augmentation_clean_v2.ipynb +0 -0
  17. notebooks/08_transformers_clean_v2.ipynb +0 -0
  18. reports/v2/01_distribucion_target.png +3 -0
  19. reports/v2/02_longitud_texto.png +3 -0
  20. reports/v2/03_wordclouds.png +3 -0
  21. reports/v2/04_correlacion_sublabels.png +3 -0
  22. reports/v2/05_multilabel_overlap.png +3 -0
  23. reports/v2/06_toxicidad_por_video.png +3 -0
  24. reports/v2/07_tokens_comparativa.png +3 -0
  25. reports/v2/08_tfidf_top_features.png +3 -0
  26. reports/v2/09_cv_metrics.png +3 -0
  27. reports/v2/10_baseline_confusion_roc.png +3 -0
  28. reports/v2/11_lr_coeficientes.png +3 -0
  29. reports/v2/12_ensemble_comparativa.png +3 -0
  30. reports/v2/13_best_model_test.png +3 -0
  31. reports/v2/14_optuna_comparativa.png +3 -0
  32. reports/v2/15_augmentation_comparativa.png +3 -0
  33. reports/v2/16_augmentation_confusion.png +3 -0
  34. reports/v2/nb08_comparativa_final.png +3 -0
  35. reports/v2/nb08_distilbert_base_cm.png +3 -0
  36. reports/v2/nb08_distilbert_toxic_(fine-tuned)_cm.png +3 -0
  37. reports/v2/nb08_roberta_hate_cm.png +3 -0
  38. src/app/app.py +764 -0
  39. src/service/model_service.py +202 -0
.gitattributes CHANGED
@@ -1,2 +1,3 @@
 
1
  *.joblib filter=lfs diff=lfs merge=lfs -text
2
  *.png filter=lfs diff=lfs merge=lfs -text
 
1
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
2
  *.joblib filter=lfs diff=lfs merge=lfs -text
3
  *.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ *.egg
11
+
12
+ # Entornos virtuales
13
+ .env
14
+ .venv
15
+ env/
16
+ venv/
17
+ ENV/
18
+
19
+ # Jupyter
20
+ .ipynb_checkpoints/
21
+ *.ipynb_checkpoints
22
+
23
+ # Datos (no subir datos al repo)
24
+ data/raw/*
25
+ data/processed/*
26
+ !data/raw/.gitkeep
27
+ !data/processed/.gitkeep
28
+
29
+ # Modelos entrenados
30
+ #models/*
31
+ !models/.gitkeep
32
+
33
+ # Logs
34
+ logs/*
35
+ !logs/.gitkeep
36
+
37
+ # Variables de entorno
38
+ .env
39
+ *.env
40
+ secrets.yaml
41
+
42
+ # IDEs
43
+ .vscode/settings.json
44
+ .idea/
45
+ *.swp
46
+ *.swo
47
+
48
+ # OS
49
+ .DS_Store
50
+ Thumbs.db
51
+
52
+ # MLflow
53
+ mlruns/
54
+ mlartifacts/
55
+
56
+ #jony
57
+ #modelos no subidos
58
+ models/roberta_hate_results/
59
+ models/distilbert_results/
60
+ models/best_distilbert/
61
+ models/nb08_distilbert/
62
+ models/nb08_roberta_hate/
63
+ models/nb08_toxic_distilbert/
64
+
65
+ models/lr_baseline.joblib
66
+ models/best_ensemble.joblib
configs/best_params.yaml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ winner: LR tuned
2
+ hyperparameters:
3
+ ngram_range: '1_2'
4
+ max_features: 4045
5
+ min_df: 2
6
+ sublinear_tf: false
7
+ C: 0.3235215031170205
8
+ results:
9
+ f1_test: 0.7579
10
+ f1_train: 0.8987
11
+ train_test_gap_pp: 14.07
12
+ cv_test_gap_pp: 4.76
13
+ roc_auc: 0.81
14
+ fp: 18
15
+ fn: 30
configs/features.yaml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ preprocessing:
2
+ lowercase: true
3
+ remove_urls: true
4
+ remove_mentions: true
5
+ remove_emojis: true
6
+ remove_special_chars: true
7
+ remove_stopwords: true
8
+ lemmatize: true
9
+ min_token_length: 2
10
+ language: en
11
+ custom_stopwords:
12
+ - youtube
13
+ - video
14
+ - watch
15
+ - like
16
+ - comment
17
+ - channel
18
+ - stefan
19
+ - peggy
20
+ - masri
21
+ - ferguson
22
+ - cigar
23
+ - hubbard
24
+
25
+ vectorization:
26
+ method: tfidf # tfidf | bow | both
27
+ tfidf:
28
+ max_features: 5000
29
+ ngram_range: [1, 2]
30
+ sublinear_tf: true
31
+ min_df: 3
32
+ bow:
33
+ max_features: 5000
34
+ ngram_range: [1, 1]
35
+ min_df: 3
configs/models.yaml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ models:
2
+ logistic_regression:
3
+ C: 0.4
4
+ max_iter: 1000
5
+ class_weight: balanced
6
+ solver: lbfgs
7
+
8
+ random_forest:
9
+ n_estimators: 100
10
+ max_depth: 10
11
+ min_samples_split: 10
12
+ min_samples_leaf: 5
13
+ max_features: sqrt
14
+ class_weight: balanced
15
+ n_jobs: -1
16
+
17
+ xgboost:
18
+ n_estimators: 100
19
+ max_depth: 3
20
+ learning_rate: 0.1
21
+ subsample: 0.8
22
+ colsample_bytree: 0.8
23
+ min_child_weight: 5
24
+ reg_lambda: 1
25
+ scale_pos_weight: 1
26
+
27
+ evaluation:
28
+ primary_metric: f1_weighted
29
+ metrics:
30
+ - accuracy
31
+ - f1_weighted
32
+ - precision_weighted
33
+ - recall_weighted
34
+ - roc_auc
configs/pipeline.yaml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ pipeline:
2
+ random_state: 42
3
+ test_size: 0.2
4
+ cv_folds: 5
5
+
6
+ data:
7
+ raw_path: data/raw/youtoxic_english_1000.csv
8
+ processed_path: data/processed/v1/comments_with_stats.csv
9
+ target_binary: IsToxic
10
+ target_multilabel:
11
+ - IsAbusive
12
+ - IsProvocative
13
+ - IsHatespeech
14
+ - IsRacist
15
+ - IsObscene
16
+ text_column: Text
17
+ id_column: CommentId
18
+
19
+ mode: binary # binary | multilabel
data/processed/v2/comments_with_stats.csv ADDED
The diff for this file is too large to render. See raw diff
 
models/.gitkeep DELETED
File without changes
models/final_model.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8a661994f9c033e18ad0346db95bd1568a526de546a77111e60d4148dc3c6e26
3
+ size 93801
notebooks/01_eda_v2.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
notebooks/02_preprocessing_v2.ipynb ADDED
@@ -0,0 +1,814 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# 🔧 Notebook 02 — Preprocesamiento de Texto\n",
8
+ "\n",
9
+ "Construimos y validamos el pipeline de limpieza de texto **paso a paso**.\n",
10
+ "\n",
11
+ "El texto crudo de YouTube tiene ruido que engaña al modelo: URLs, menciones, caracteres raros (`\\xa0`), contracciones rotas (`don t`).\n",
12
+ "Antes de vectorizar necesitamos texto limpio y normalizado.\n",
13
+ "\n",
14
+ "### Herramientas\n",
15
+ "- **`re`** → expresiones regulares para limpiar ruido estructural\n",
16
+ "- **`NLTK`** → lista curada de 179 stopwords en inglés\n",
17
+ "- **`spaCy`** → lematización con modelo de lenguaje real `en_core_web_sm`\n",
18
+ "- **`MLflow`** → registrar qué configuración de preprocesamiento usamos"
19
+ ]
20
+ },
21
+ {
22
+ "cell_type": "markdown",
23
+ "metadata": {},
24
+ "source": [
25
+ "## 0. Imports y configuración\n",
26
+ "\n",
27
+ "Cargamos todo desde el YAML. Ningún valor hardcodeado en el notebook."
28
+ ]
29
+ },
30
+ {
31
+ "cell_type": "code",
32
+ "execution_count": 2,
33
+ "metadata": {},
34
+ "outputs": [
35
+ {
36
+ "name": "stdout",
37
+ "output_type": "stream",
38
+ "text": [
39
+ "PROJECT_ROOT : /mnt/c/Users/under/Documents/F5/3_Projects/Project_9_Equipo3/Project_YT\n",
40
+ "Python : 3.10.20\n"
41
+ ]
42
+ }
43
+ ],
44
+ "source": [
45
+ "import re\n",
46
+ "import sys\n",
47
+ "import yaml\n",
48
+ "import pandas as pd\n",
49
+ "import numpy as np\n",
50
+ "import matplotlib.pyplot as plt\n",
51
+ "import seaborn as sns\n",
52
+ "import mlflow\n",
53
+ "import nltk\n",
54
+ "import spacy\n",
55
+ "from nltk.corpus import stopwords\n",
56
+ "from pathlib import Path\n",
57
+ "import warnings\n",
58
+ "warnings.filterwarnings('ignore')\n",
59
+ "\n",
60
+ "# Ruta raiz — \n",
61
+ "PROJECT_ROOT = Path.cwd().parent\n",
62
+ "sys.path.insert(0, str(PROJECT_ROOT))\n",
63
+ "\n",
64
+ "plt.rcParams['figure.figsize'] = (12, 5)\n",
65
+ "plt.rcParams['axes.spines.top'] = False\n",
66
+ "plt.rcParams['axes.spines.right'] = False\n",
67
+ "\n",
68
+ "print(f'PROJECT_ROOT : {PROJECT_ROOT}')\n",
69
+ "print(f'Python : {sys.version.split()[0]}')"
70
+ ]
71
+ },
72
+ {
73
+ "cell_type": "code",
74
+ "execution_count": 3,
75
+ "metadata": {},
76
+ "outputs": [
77
+ {
78
+ "name": "stdout",
79
+ "output_type": "stream",
80
+ "text": [
81
+ "Configuracion de preprocesamiento:\n",
82
+ " lowercase: True\n",
83
+ " remove_urls: True\n",
84
+ " remove_mentions: True\n",
85
+ " remove_emojis: True\n",
86
+ " remove_special_chars: True\n",
87
+ " remove_stopwords: True\n",
88
+ " lemmatize: True\n",
89
+ " min_token_length: 2\n",
90
+ " language: en\n",
91
+ " custom_stopwords: ['youtube', 'video', 'watch', 'like', 'comment', 'channel', 'stefan', 'peggy', 'masri', 'ferguson', 'cigar', 'hubbard']\n"
92
+ ]
93
+ }
94
+ ],
95
+ "source": [
96
+ "# Carga de configuracion desde YAML\n",
97
+ "CONFIG_PATH = PROJECT_ROOT / 'configs' / 'features.yaml'\n",
98
+ "\n",
99
+ "with open(CONFIG_PATH) as f:\n",
100
+ " config = yaml.safe_load(f)\n",
101
+ "\n",
102
+ "prep_cfg = config['preprocessing']\n",
103
+ "print('Configuracion de preprocesamiento:')\n",
104
+ "for k, v in prep_cfg.items():\n",
105
+ " print(f' {k}: {v}')"
106
+ ]
107
+ },
108
+ {
109
+ "cell_type": "code",
110
+ "execution_count": 4,
111
+ "metadata": {},
112
+ "outputs": [
113
+ {
114
+ "name": "stdout",
115
+ "output_type": "stream",
116
+ "text": [
117
+ "spaCy : core_web_sm v3.8.0\n",
118
+ "NLTK : 198 stopwords en ingles\n"
119
+ ]
120
+ }
121
+ ],
122
+ "source": [
123
+ "# Descargar recursos NLTK\n",
124
+ "nltk.download('stopwords', quiet=True)\n",
125
+ "nltk.download('punkt', quiet=True)\n",
126
+ "\n",
127
+ "# Cargar modelo spaCy\n",
128
+ "# Si no lo tienes instalado: python -m spacy download en_core_web_sm\n",
129
+ "nlp = spacy.load('en_core_web_sm', disable=['parser', 'ner'])\n",
130
+ "\n",
131
+ "print(f\"spaCy : {nlp.meta['name']} v{nlp.meta['version']}\")\n",
132
+ "print(f'NLTK : {len(stopwords.words(\"english\"))} stopwords en ingles')"
133
+ ]
134
+ },
135
+ {
136
+ "cell_type": "markdown",
137
+ "metadata": {},
138
+ "source": [
139
+ "## 1. Carga de datos\n",
140
+ "\n",
141
+ "Las rutas vienen del YAML de pipeline, no se escriben a mano."
142
+ ]
143
+ },
144
+ {
145
+ "cell_type": "code",
146
+ "execution_count": 5,
147
+ "metadata": {},
148
+ "outputs": [
149
+ {
150
+ "name": "stdout",
151
+ "output_type": "stream",
152
+ "text": [
153
+ "Dataset : (1000, 15)\n",
154
+ "Texto : \"Text\"\n",
155
+ "Target : \"IsToxic\"\n",
156
+ "Sublabels: ['IsAbusive', 'IsProvocative', 'IsHatespeech', 'IsRacist', 'IsObscene']\n",
157
+ "\n",
158
+ "Muestra texto crudo:\n",
159
+ " -> \"You call yourself an anarchist but defend a cop shooting an unarmed civilian. I'm highly disappointe\"\n",
160
+ " -> 'My mother told me the same thing.\\xa0 God Bless this woman.'\n",
161
+ " -> 'Love it I same the saem thing Go Peggy! #stupid \\xa0\\nYa Killing ya selves more quicker than\\xa0 STLPD cou'\n"
162
+ ]
163
+ }
164
+ ],
165
+ "source": [
166
+ "PIPELINE_PATH = PROJECT_ROOT / 'configs' / 'pipeline.yaml'\n",
167
+ "\n",
168
+ "with open(PIPELINE_PATH) as f:\n",
169
+ " pipeline_cfg = yaml.safe_load(f)\n",
170
+ "\n",
171
+ "DATA_PATH = PROJECT_ROOT / pipeline_cfg['data']['raw_path']\n",
172
+ "TEXT_COL = pipeline_cfg['data']['text_column']\n",
173
+ "TARGET_BIN = pipeline_cfg['data']['target_binary']\n",
174
+ "SUBLABELS = pipeline_cfg['data']['target_multilabel']\n",
175
+ "\n",
176
+ "df = pd.read_csv(DATA_PATH)\n",
177
+ "print(f'Dataset : {df.shape}')\n",
178
+ "print(f'Texto : \"{TEXT_COL}\"')\n",
179
+ "print(f'Target : \"{TARGET_BIN}\"')\n",
180
+ "print(f'Sublabels: {SUBLABELS}')\n",
181
+ "print()\n",
182
+ "print('Muestra texto crudo:')\n",
183
+ "for t in df[TEXT_COL].sample(3, random_state=42):\n",
184
+ " print(f' -> {repr(t[:100])}')"
185
+ ]
186
+ },
187
+ {
188
+ "cell_type": "markdown",
189
+ "metadata": {},
190
+ "source": [
191
+ "## 2. Pipeline paso a paso\n",
192
+ "\n",
193
+ "Construimos cada función por separado para entender su efecto antes de encadenarlas.\n",
194
+ "\n",
195
+ "```\n",
196
+ "texto raw\n",
197
+ " → [1] lowercase\n",
198
+ " → [2] regex: URLs, @menciones, \\xa0, contracciones rotas, números\n",
199
+ " → [3] spaCy: tokenización + lematización\n",
200
+ " → [4] NLTK: filtrado stopwords + tokens cortos + puntuación\n",
201
+ " → texto limpio\n",
202
+ "```"
203
+ ]
204
+ },
205
+ {
206
+ "cell_type": "code",
207
+ "execution_count": 6,
208
+ "metadata": {},
209
+ "outputs": [
210
+ {
211
+ "name": "stdout",
212
+ "output_type": "stream",
213
+ "text": [
214
+ "PASO 1 — Lowercase\n",
215
+ "-----------------------------------------------------------------\n",
216
+ " ANTES : Stephan, Thank you for the video. It takes all the available\n",
217
+ " DESPUES: stephan, thank you for the video. it takes all the available\n",
218
+ "\n",
219
+ " ANTES : Body cams should also air what the police run into day to da\n",
220
+ " DESPUES: body cams should also air what the police run into day to da\n",
221
+ "\n",
222
+ " ANTES : This is a really sad story. Someone is killed for no reason,\n",
223
+ " DESPUES: this is a really sad story. someone is killed for no reason,\n",
224
+ "\n"
225
+ ]
226
+ }
227
+ ],
228
+ "source": [
229
+ "# ── PASO 1: Lowercase ──\n",
230
+ "# Por que: 'BLACK' y 'black' son la misma palabra.\n",
231
+ "# Sin esto el modelo aprende 'BLACK' y 'black' como features distintas.\n",
232
+ "\n",
233
+ "def to_lowercase(text: str) -> str:\n",
234
+ " \"\"\"Convierte el texto a minusculas.\"\"\"\n",
235
+ " return str(text).lower()\n",
236
+ "\n",
237
+ "# Validacion visual\n",
238
+ "print('PASO 1 — Lowercase')\n",
239
+ "print('-' * 65)\n",
240
+ "for ex in df[TEXT_COL].sample(3, random_state=1).tolist():\n",
241
+ " print(f' ANTES : {ex[:60]}')\n",
242
+ " print(f' DESPUES: {to_lowercase(ex)[:60]}')\n",
243
+ " print()"
244
+ ]
245
+ },
246
+ {
247
+ "cell_type": "code",
248
+ "execution_count": 7,
249
+ "metadata": {},
250
+ "outputs": [
251
+ {
252
+ "name": "stdout",
253
+ "output_type": "stream",
254
+ "text": [
255
+ "PASO 2 — Limpieza Regex\n",
256
+ "-----------------------------------------------------------------\n",
257
+ " ANTES : 'Check this out http://youtube.com/watch?v=abc123 ok?'\n",
258
+ " DESPUES: 'Check this out ok?'\n",
259
+ "\n",
260
+ " ANTES : \"Hey @username you're so stupid\\xa0\\xa0 really\"\n",
261
+ " DESPUES: 'Hey youre so stupid really'\n",
262
+ "\n",
263
+ " ANTES : 'dont\\nyou see?\\n\\nThis is wrong!!!'\n",
264
+ " DESPUES: 'dont you see? This is wrong!!!'\n",
265
+ "\n",
266
+ " ANTES : 'He has 100 guns and 50 knives'\n",
267
+ " DESPUES: 'He has guns and knives'\n",
268
+ "\n"
269
+ ]
270
+ }
271
+ ],
272
+ "source": [
273
+ "# ── PASO 2: Limpieza con Regex ────────────────────────────────────────────\n",
274
+ "# Por que regex: hay ruido sistematico en comentarios de YouTube.\n",
275
+ "# El EDA mostro: \\xa0 embebidos, saltos de linea, URLs, @menciones.\n",
276
+ "\n",
277
+ "\n",
278
+ "def clean_regex(text: str) -> str:\n",
279
+ " \"\"\"Limpieza con expresiones regulares.\"\"\"\n",
280
+ " # URLs completas\n",
281
+ " text = re.sub(r'http\\S+|www\\.\\S+', '', text)\n",
282
+ " # Menciones @usuario\n",
283
+ " text = re.sub(r'@\\w+', '', text)\n",
284
+ " # Saltos de linea y tabulaciones -> espacio\n",
285
+ " text = re.sub(r'[\\n\\t\\r]', ' ', text)\n",
286
+ " # Caracteres no-ASCII: \\xa0, emojis, caracteres especiales\n",
287
+ " text = re.sub(r'[^\\x00-\\x7F]+', ' ', text)\n",
288
+ " # Apostrofes: \"don't\" -> \"dont\", \"it's\" -> \"its\"\n",
289
+ " text = re.sub(r\"'\", '', text)\n",
290
+ " # Numeros solos sin contexto lexico util\n",
291
+ " text = re.sub(r'\\b\\d+\\b', '', text)\n",
292
+ " # Espacios multiples -> uno solo\n",
293
+ " text = re.sub(r'\\s+', ' ', text)\n",
294
+ " return text.strip()\n",
295
+ "\n",
296
+ "# Validacion con casos problematicos reales del dataset (ejemplo)\n",
297
+ "print('PASO 2 — Limpieza Regex')\n",
298
+ "print('-' * 65)\n",
299
+ "test_cases = [\n",
300
+ " 'Check this out http://youtube.com/watch?v=abc123 ok?',\n",
301
+ " 'Hey @username you\\'re so stupid\\xa0\\xa0 really',\n",
302
+ " 'dont\\nyou see?\\n\\nThis is wrong!!!',\n",
303
+ " 'He has 100 guns and 50 knives',\n",
304
+ "]\n",
305
+ "for tc in test_cases:\n",
306
+ " print(f' ANTES : {repr(tc)}')\n",
307
+ " print(f' DESPUES: {repr(clean_regex(tc))}')\n",
308
+ " print()"
309
+ ]
310
+ },
311
+ {
312
+ "cell_type": "code",
313
+ "execution_count": 8,
314
+ "metadata": {},
315
+ "outputs": [
316
+ {
317
+ "name": "stdout",
318
+ "output_type": "stream",
319
+ "text": [
320
+ "PASO 3+4 — Lematizacion (spaCy) + Filtrado (NLTK)\n",
321
+ "-----------------------------------------------------------------\n",
322
+ " ANTES : black people are being killed by cops\n",
323
+ " DESPUES: black people kill cop\n",
324
+ "\n",
325
+ " ANTES : you are so stupid and racist thug\n",
326
+ " DESPUES: stupid racist thug\n",
327
+ "\n",
328
+ " ANTES : running faster than the police officers\n",
329
+ " DESPUES: run fast police officer\n",
330
+ "\n",
331
+ " ANTES : these cops are criminals and killers\n",
332
+ " DESPUES: cop criminal killer\n",
333
+ "\n",
334
+ " ANTES : Hi, stefan\n",
335
+ " DESPUES: hi\n",
336
+ "\n"
337
+ ]
338
+ }
339
+ ],
340
+ "source": [
341
+ "# ── PASO 3 + 4: Lematizacion con spaCy + filtrado con NLTK ───────────────\n",
342
+ "#\n",
343
+ "# Por que spaCy para LEMATIZAR:\n",
344
+ "# Conoce gramatica inglesa real. // 'running' -> 'run' | 'cops' -> 'cop' | 'better' -> 'good'\n",
345
+ "\n",
346
+ "# Por que NLTK para STOPWORDS:\n",
347
+ "# Lista curada de 179 palabras funcionales (the, is, at, which...)\n",
348
+ "# Mas explicita y facil de personalizar que la lista interna de spaCy\n",
349
+ "\n",
350
+ "\n",
351
+ "STOP_WORDS = set(stopwords.words('english'))\n",
352
+ "\n",
353
+ "# Stopwords custom: palabras tematicas sin valor discriminante\n",
354
+ "CUSTOM_STOPWORDS = set(config['preprocessing']['custom_stopwords'])\n",
355
+ "STOP_WORDS = STOP_WORDS | CUSTOM_STOPWORDS\n",
356
+ "\n",
357
+ "MIN_TOKEN_LEN = prep_cfg.get('min_token_length', 2)\n",
358
+ "\n",
359
+ "def lemmatize_and_filter(text: str) -> str:\n",
360
+ " \"\"\"Lematiza con spaCy y filtra stopwords con NLTK.\"\"\"\n",
361
+ " doc = nlp(text) # Separación de palabras\n",
362
+ " tokens = [\n",
363
+ " token.lemma_\n",
364
+ " for token in doc\n",
365
+ " if not token.is_punct # sin puntuacion\n",
366
+ " and not token.is_space # sin espacios\n",
367
+ " and len(token.text) >= MIN_TOKEN_LEN # tokens de al menos 2 chars\n",
368
+ " and token.lemma_ not in STOP_WORDS # sin stopwords\n",
369
+ " ]\n",
370
+ " return ' '.join(tokens)\n",
371
+ "\n",
372
+ "\n",
373
+ "# Validacion: ver exactamente que hace la lematizacion (ejemplo)\n",
374
+ "print('PASO 3+4 — Lematizacion (spaCy) + Filtrado (NLTK)')\n",
375
+ "print('-' * 65)\n",
376
+ "test_texts = [\n",
377
+ " 'black people are being killed by cops',\n",
378
+ " 'you are so stupid and racist thug',\n",
379
+ " 'running faster than the police officers',\n",
380
+ " 'these cops are criminals and killers',\n",
381
+ " 'Hi, stefan'\n",
382
+ "]\n",
383
+ "for tt in test_texts:\n",
384
+ " result = lemmatize_and_filter(tt)\n",
385
+ " print(f' ANTES : {tt}')\n",
386
+ " print(f' DESPUES: {result}')\n",
387
+ " print()"
388
+ ]
389
+ },
390
+ {
391
+ "cell_type": "code",
392
+ "execution_count": 9,
393
+ "metadata": {},
394
+ "outputs": [
395
+ {
396
+ "name": "stdout",
397
+ "output_type": "stream",
398
+ "text": [
399
+ "PIPELINE COMPLETO — Ejemplos reales del dataset\n",
400
+ "======================================================================\n",
401
+ "[NO TOXICO ]\n",
402
+ " CRUDO : You call yourself an anarchist but defend a cop shooting an unarmed civilian. I'm highly disappointe\n",
403
+ " LIMPIO : call anarchist defend cop shoot unarmed civilian highly disappointed beginning refer square know blu\n",
404
+ "\n",
405
+ "[NO TOXICO ]\n",
406
+ " CRUDO : My mother told me the same thing.  God Bless this woman.\n",
407
+ " LIMPIO : mother tell I thing god bless woman\n",
408
+ "\n",
409
+ "[NO TOXICO ]\n",
410
+ " CRUDO : Love it I same the saem thing Go Peggy! #stupid  \n",
411
+ "Ya Killing ya selves more quicker than  STLPD cou\n",
412
+ " LIMPIO : love saem thing go stupid ya kill ya self quick stlpd could ever wakeup\n",
413
+ "\n",
414
+ "[TOXICO ]\n",
415
+ " CRUDO : Next time they do that, line up some cars and start making burnout smoke to \"riot gas\" them. Non let\n",
416
+ " LIMPIO : next time line car start make burnout smoke riot gas non lethal eficient double check actualy non le\n",
417
+ "\n",
418
+ "[NO TOXICO ]\n",
419
+ " CRUDO : He was Robbing the Store and Being a Big Man .\n",
420
+ "If you Play with Fire you Will Get Burnt .?\n",
421
+ "The Polic\n",
422
+ " LIMPIO : rob store big man play fire get burn police job\n",
423
+ "\n",
424
+ "[NO TOXICO ]\n",
425
+ " CRUDO : The crazy thing is I thought offices never do anything I want of random people over\n",
426
+ " LIMPIO : crazy thing think office never anything want random people\n",
427
+ "\n"
428
+ ]
429
+ }
430
+ ],
431
+ "source": [
432
+ "# ── PIPELINE COMPLETO ──\n",
433
+ "\n",
434
+ "def preprocess_text(text: str) -> str:\n",
435
+ " \"\"\"\n",
436
+ " Pipeline completo de preprocesamiento NLP.\n",
437
+ "\n",
438
+ " Pasos:\n",
439
+ " 1. lowercase\n",
440
+ " 2. limpieza regex (URLs, menciones, chars especiales)\n",
441
+ " 3. lematizacion con spaCy\n",
442
+ " 4. filtrado stopwords con NLTK\n",
443
+ "\n",
444
+ " \"\"\"\n",
445
+ " text = to_lowercase(text)\n",
446
+ " text = clean_regex(text)\n",
447
+ " text = lemmatize_and_filter(text)\n",
448
+ " return text\n",
449
+ "\n",
450
+ "# Verificacion con ejemplos reales del dataset\n",
451
+ "print('PIPELINE COMPLETO — Ejemplos reales del dataset')\n",
452
+ "print('=' * 70)\n",
453
+ "for _, row in df.sample(6, random_state=42).iterrows():\n",
454
+ " label = 'TOXICO ' if row[TARGET_BIN] else 'NO TOXICO '\n",
455
+ " print(f'[{label}]')\n",
456
+ " print(f' CRUDO : {row[TEXT_COL][:100]}')\n",
457
+ " print(f' LIMPIO : {preprocess_text(row[TEXT_COL])[:100]}')\n",
458
+ " print()"
459
+ ]
460
+ },
461
+ {
462
+ "cell_type": "markdown",
463
+ "metadata": {},
464
+ "source": [
465
+ "## 3. Aplicar el pipeline al dataset completo\n",
466
+ "\n",
467
+ "Procesamos las 1000 filas y validamos que no haya pérdida de información crítica."
468
+ ]
469
+ },
470
+ {
471
+ "cell_type": "code",
472
+ "execution_count": 10,
473
+ "metadata": {},
474
+ "outputs": [
475
+ {
476
+ "name": "stdout",
477
+ "output_type": "stream",
478
+ "text": [
479
+ "Procesando dataset completo...\n",
480
+ "Completado: 1000 comentarios procesados\n"
481
+ ]
482
+ }
483
+ ],
484
+ "source": [
485
+ "print('Procesando dataset completo...')\n",
486
+ "df['clean_text'] = df[TEXT_COL].apply(preprocess_text)\n",
487
+ "print(f'Completado: {len(df)} comentarios procesados')"
488
+ ]
489
+ },
490
+ {
491
+ "cell_type": "code",
492
+ "execution_count": 11,
493
+ "metadata": {},
494
+ "outputs": [
495
+ {
496
+ "name": "stdout",
497
+ "output_type": "stream",
498
+ "text": [
499
+ " CRUDO LIMPIO REDUCCION\n",
500
+ "----------------------------------------------------\n",
501
+ " mean 33.8 16.5 51.2%\n",
502
+ " median 19.0 9.0 52.6%\n",
503
+ " min 1.0 1.0 0.0%\n",
504
+ " max 815.0 373.0 54.2%\n",
505
+ "\n",
506
+ "Comentarios vacios tras limpieza : 0\n"
507
+ ]
508
+ }
509
+ ],
510
+ "source": [
511
+ "# Estadisticas antes vs despues\n",
512
+ "df['tokens_raw'] = df[TEXT_COL].str.split().str.len()\n",
513
+ "df['tokens_clean'] = df['clean_text'].str.split().str.len()\n",
514
+ "\n",
515
+ "print(f\"{'':20} {'CRUDO':>8} {'LIMPIO':>8} {'REDUCCION':>11}\")\n",
516
+ "print('-' * 52)\n",
517
+ "for stat in ['mean', 'median', 'min', 'max']:\n",
518
+ " raw = getattr(df['tokens_raw'], stat)()\n",
519
+ " clean = getattr(df['tokens_clean'], stat)()\n",
520
+ " pct = (1 - clean / raw) * 100 if raw > 0 else 0\n",
521
+ " print(f' {stat:18} {raw:8.1f} {clean:8.1f} {pct:10.1f}%')\n",
522
+ "\n",
523
+ "print()\n",
524
+ "empty_after = (df['tokens_clean'] == 0).sum()\n",
525
+ "print(f'Comentarios vacios tras limpieza : {empty_after}')"
526
+ ]
527
+ },
528
+ {
529
+ "cell_type": "code",
530
+ "execution_count": 12,
531
+ "metadata": {},
532
+ "outputs": [
533
+ {
534
+ "data": {
535
+ "image/png": "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/tU0zaJ/r5L83QAAoKzxPPenkvYkvPXWW63jBZ8jnnjiCet448aNrePp6emF2g4NDbWOm6ZppqSkWGXjxo0zTbPonnAHDx60RqC4XC7zjz/+sNp46KGHrPoPPPCAaZqm+Y9//MM69v777/vUPxMF77tgz7n09HTrN5Fk7tmz56xjK/h92Gw288cffzyjmEryvF2wJ2FWVpY5ZswYs2nTpj73cGJr2bKlVfdEr8jmzZub3377rZmTk1PoOgXvOy4uzvoz/uWXX5p/+ctfrHY/+OCDM7o/4EJHT0IggP3+++86fPiwJCk0NFRNmjSxytq2bWt9/umnnwqde/DgQe3atUvS8Z5tHTt2tMr++OMPq928vDx16dJFV155pa688kpNmzbNqvfDDz8Uardhw4bWCnA2m816y5eenm7VGTx4sCTpt99+U2JiosLDw1WvXj3dddddRcZ6pgqee+21157ROe3bt1dMTEyJrylJ//vf/6zPl112mfW54G9QUME427VrV2T9030PYWFh1lveb775Rrt27dKSJUus7zklJcWqe/vtt8swDGVnZ6tLly6KiopS9erVddttt2nDhg2nv8ESKng/Bb/jNm3aWJ8rVapkfS74Z6TgefXq1SuyzYLf+8nO5e8GAADliee5s1MazxcNGzb0WT35TJ8vfv75Z2sewbp166pixYpFtnHi/lNSUqx5KG+66SZVqlRJVapU0Q033KAlS5YUf5NFKPjMGBUVpQYNGvjEfLaxFVS/fn2f9k6lJM/bBfXr109jxozR5s2blZ2dXai8qD9j3333nVq2bKmwsDA1atRIDz74oFJTUyUd72F54r7T0tKsP+NXXnmlz7yFRf05By5GJAkBSFKh4QqnG75wYliFJE2cOFH79+8/62sW1W2/YNd/6fgQ15P97W9/0+eff67+/furSZMmCgoK0s6dO/XKK6+oY8eORT7MlZUTD8AFFfzuTiz8ccKpFiM5VTtlUf/kIccnhhobhqFbb73VKuvatatWr16tO+64Qy1btlRoaKj27t2r2bNnq2PHjqd8GD4XBR++Cw49iYyMLLL+iQe8Uznb76ioc0rSBgAA5YHnudM7X54vzqSNJk2aaOPGjbr//vvVrl07RUVF6cCBA/roo4+UnJysNWvWlOr1Slq3qOfhsrBnzx598sknkqTw8HBNmzZNK1as0IoVK6w6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eOG8n06OJCEAACixypUr6/7775ck9evXT3/72980ePBgVaxYUZLUsWNHffPNN1aS0Ov1atKkSQoNDVXDhg01dOhQvfXWW4WShL/99puWLVumtLQ0hYeHKzw8XCNHjtSYMWNIEgIAAJSxsp4Kp6CSTosTHBysSZMm6a677lKFChXKKLrSExUV5e8QTovhxgAAoMSqVKlifQ4NDS3yWGZmprUfHBys2NhYa79mzZr67bffCrW7d+9eBQcH+7RVp04d7d27t1TjBwAAQNHKciqc0pgWp0uXLoqLi9OECRNOWe/DDz9U48aN5XK5VKtWLf3rX/8qtu7MmTM1duxYfffddzIMQ4ZhaObMmZKkPXv2qFevXgoPD1dkZKRuvvlm7d+/X5L0448/KjQ0VHPmzLHaeu+99xQSEqJt27ZJKjzc2Ov1avLkyapXr55cLpdq1Kihp556yirfvHmzOnXqpJCQEFWsWFF33nmnz3N1WSBJCAAAyk1OTo4OHDhg7e/Zs0fVqlUrVC8hIUE5OTnWg5ck7d69WwkJCeUSJwAAAM5vdrtdTz/9tF588cViXyRv3LhRN998s/r27avNmzdrzJgxeuKJJ6zE38luueUWPfTQQ2rcuLFSU1OVmpqqW265RV6vV7169dKhQ4e0cuVKLV68WP/73/90yy23SJIaNmyoZ555Rvfee6/27NmjvXv36u6779akSZPUqFGjIq81YsQITZw4UU888YS2bdumOXPmWC/Is7KylJycrAoVKujrr7/W+++/ryVLlmjo0KHn/sWdAsONAQBAkfLz85WTk2PtG4Zxzm3abDaNGDFCL730kvbs2aOpU6dqzJgxhepVq1ZNV199tf7+97/r5Zdf1sGDB/XUU08VOXchAAAAAtNf/vIXtWjRQqNHj9brr79eqPzZZ59V586d9cQTT0iSLrnkEm3btk3//Oc/NWjQoEL1Q0JCFB4eLofDobi4OOv44sWLtXnzZu3atUvVq1eXJM2aNUuNGzfW119/rcsuu0z33nuvFixYoNtuu01BQUG67LLLdN999xUZ99GjR/XCCy/opZdesp5v69atqw4dOkiS5syZo5ycHM2aNUthYWGSpJdeeknXXXedJk2a5DPapjSRJPQjw7Dp0uoNFGS3y274duq02QzVrV9Rdrtks5/7/5QBAM5/IXbnedX2ww8/rIcfftjar1mz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gwbJ7XZLOt4TsFevXoqNjVVUVJSuuuoqfffdd2d8v4ZhWHPjjRkzRtdee63uuusuRUVFqXbt2lqxYoXmzZunevXqqUKFCj73cuI+HnvsMVWsWFE1atTQtGnTrPKThzbv2LFDycnJiomJUd26dfX888+fcZwLFy5Uq1atdO2118pms+naa69V27ZtNWvWrDNuAwAAADgZSUI/MgxD1SrGq2blBNlkFCqrEheuqvERMviVAOCil5WbU+yW484747rH8oqve7aio6PVrVs3a5jsG2+8odtvv92nTnZ2tjp37qzOnTvrt99+05dffqm5c+dqxowZkqRly5Zp5MiReu+995SamipJ2rJlS7HXrFmzppYtW6aMjAy99tprevjhh7V69WqfOp9//rm+/fZbbdu2TUuXLtXs2bMlSV6vV7feeqt27dql/fv3q2XLlrr55ptP27OtOIsWLVJycrIOHTqk/v3767bbbtPHH3+s7777TqtXr9a//vUvffPNN1b9LVu2yDAMpaam6t1339U//vEPrVq1qlC7+fn5uvbaa9W8eXPt27dPH330kSZPnqw5c+ZYdaKjo/XVV18VGZfX6y10T16vV99//32J7hMAAACQGG7sVw67Qzdd2VtRLpdcziBJf/a6cDhs6tazgUJCbAoKsvsvSABAuaj2yMBiy7o2aqn37vqHtV//8TuVnZdbZN329Rrps/tGW/vNxg7VwayjkqT0F94967huv/12Pf744xo4cKA+/PBDbdmyRf/4x5+xfPbZZ6pQoYI1R2GNGjX0wAMPaM6cORo8eLBmz56tlJQUJSYmSjreo65g78CT9enTx/p89dVXKzk5WStWrFD79u2t46NGjVJERIQiIiLUrVs3bdy4UYMGDVJkZKRuueUWq97YsWM1ZcoU7du3T9WqVTvre2/durVuuOEGScd7VY4fP17/+Mc/FBYWpkaNGqlZs2b65ptv1KpVK0lSWFiYxowZI6fTqcTERKWkpGjWrFm66qqrfNpdv369UlNT9eSTTyooKEjNmjXT0KFDNXPmTN16662SpPT09GLjuuaaa/T3v/9d8+bN07XXXqv58+dr9erVSkpKOut7BAAAAE6gjxoAAChW586dlZqaqvHjxysxMVFxcXE+5bt379aWLVsUHR1tbQ899JDS0tIkSfv27VPNmjWt+k6nU1WrVi32erNnz1ar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TSEhNtnthp8iBAAAAAAAQCAgSehHdptdbeq3VJTLJafdIenP1YvtdpuaNItTdAWHHA46fAIAAAAAAKDs+DX7NGHCBF122WWKiIhQbGysevfure3bt/vUycnJ0ZAhQ1SxYkWFh4erT58+2r9/v0+dPXv2qGfPngoNDVVsbKwefvhh5efnl+etAAAAAAAAABcsvyYJV65cqSFDhmjdunVavHix3G63unbtqqysLKvO8OHD9emnn+r999/XypUrtW/fPt1www1WucfjUc+ePZWXl6c1a9bozTff1MyZMzVq1Ch/3NJZ8ZpepR3er98Opsnj9fqWeU398XuW0lIz5fWafooQAAAAAAAAgcCvScKFCxdq0KBBaty4sZo3b66ZM2dqz5492rhxoyTpyJEjev311/Xss8+qU6dOat26tWbMmKE1a9Zo3bp1kqRFixZp27Ztevvtt9WiRQt1795d48eP19SpU5WXl+fP2zstj8ejuSs/1GtL3lGex31SmVefffKj3p29VW63t5gWAAAAzj+rVq3Sddddp/j4eBmGoXnz5vmUm6apUaNGqWrVqgoJCVGXLl30888/+9Q5dOiQUlJSFBkZqejoaA0ePFiZmZnleBcAAACB5bya7O7IkSOSpJiYGEnSxo0b5Xa71aVLF6tOw4YNVaNGDa1du1aStHbtWjVt2lRVqlSx6iQnJysjI0Nbt24t8jq5ubnKyMjw2QAAAFA6srKy1Lx5c02dOrXI8smTJ2vKlCl6+eWXtX79eoWFhSk5OVk5OTlWnZSUFG3dulWLFy/W/PnztWrVKt15553ldQsAAAAB57xZuMTr9WrYsGFq3769mjRpIklKS0tTUFCQoqOjfepWqVJFaWlpVp2CCcIT5SfKijJhwgSNHTu2lO8AAAAAktS9e3d17969yDLTNPX888/r8ccfV69evSRJs2bNUpUqVTRv3jz17dtXP/zwgxYuXKivv/5abdq0kSS9+OKL6tGjh5555hnFx8eX270AAAAEivOmJ+GQIUO0ZcsWzZ07t8yvNWLECB05csTafv311zK/JgAAAKRdu3YpLS3NZ6RIVFSU2rVr5zNSJDo62koQSlKXLl1ks9m0fv36IttlpAgAAMC5OS+ShEOHDtX8+fO1fPlyJSQkWMfj4uKUl5en9PR0n/r79+9XXFycVefk1Y5P7J+oczKXy6XIyEif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8KAcPHhQAMhLL70kBw8elHPnzomIyPLlyyU6Olo++OADOXz4sEyZMkX69OkjtbW13n1kZ2fLiBEjZO/evbJr1y6588475ZFHHumoJt2Sn/70p9KtWzfJz8/3eY26zWbzlpk9e7YkJyfLp59+Kvv375eMjAzJyMjwrq9/zfq3v/1tKSoqkk2bNkmPHj26zGvWlyxZItu3b5fS0lI5fPiwLFmyRBRFkY8//lhEQr/9gTR8c1aox2HhwoWSn58vpaWlsnv3bsnMzJS4uDiprKwUkdBvv4hIYWGh6PV6ef7556WkpETefvttsVgs8tZbb3nLhPo5UeTGW16Tk5Nl8eLFjdZpoR/k5uZKr169ZP369VJaWirvv/++xMXFyb//+797y2ihH4S6UB/fBet2z/2hROtj4qY0FZvq6mp56qmnpKCgQEpLS+WTTz6Ru+++W+68806pq6vz7iNUY8NricCai82pU6fkueeek/3790tpaal88MEH0rdvX5kwYYJ3H6EaG15/BdZUbLTcZ4LFJGE7ee211yQ5OVmMRqOMHj1a9uzZ09FVajXbtm0TAI3+5ebmioiIx+ORX/7yl5KQkCAmk0kmTpwoxcXFPvu4fPmyPPLIIxIRESFRUVHy4x//WKqrqzugNbfOX9sByMqVK71lamtr5V//9V8lJiZGLBaLfOc735Hy8nKf/Zw9e1YmTZokZrNZ4uLiZOHCheJ0Otu5NS0zY8YMSUlJEaPRKD169JCJEyd6/0CJhH77A2mYJAz1OEybNk169uwpRqNRevXqJdOmTZNTp05514d6++v93//9n9x1111iMplk0KBB8qc//clnfaifE0VENm/eLAAatUtEG/2gqqpK5s+fL8nJyRIWFiZ9+/aVn//852K3271ltNAPtCCUx3fBao1zf6jQ+pi4KU3Fxmazybe//W3p0aOHGAwGSUlJkSeeeKJRwj1UY8NricCai01ZWZlMmDBBunfvLiaTSfr37y+LFi2S69ev++wnFGPD66/AmoqNlvtMsBQRkTa9VZGIiIiIiIiIiIg6Nc5JSEREREREREREpHFMEhIREREREREREWkck4REREREREREREQaxyQhERERERERERGRxjFJSEREREREREREpHFMEhIREREREREREWkck4REREREREREREQaxyQhERERERERERGRxjFJSETUyeTn50NR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iIiIKHgcd3Hc1Zxb/XzbCseTRG2DSUIi6vSWL1+O4cOHeyeprtejRw9UVFRARLxzkBQVFd328e6++26sWbMG8fHx3oFaMAYPHoxVq1ahpqbGO5DdvXs3VFVtVPebt/nwww99lu3Zs6dRfY4fP47+/fsHXRej0egzYXT9sc6fP4/z5897v9U+fvw4rl27hiFDhnjLmc1mrF+/Hg888ACysrLw8ccfIzIyEiNGjIDb7UZlZSXGjx8fdF38GTFiBEaMGIG8vDxkZGTgnXfe4aCOiIioE+C4i+OuttAwznv27MHgwYP9lg32s+0M7SIKNXzcmIg6vbS0NOTk5ODVV1/1Wf6Nb3wDly5dwu9+9zucPn0aK1aswEcffXTbx8vJyUFcXBymTJmCnTt3orS0FPn5+Zg3b16Tk3nn5OQgLCwMubm5OHr0KLZt24Z/+7d/w49+9CMkJCT43Wb27NkoKSnBokWLUFxcjHfeecf7Rrx6ixcvxt///nfMnTsXRUVFKCkpwQcffNDkxNypqakoLS1FUVERvvzyS9jtdmRmZnpj+dlnn6GwsBCPPfYY7r//fowaNcpn+/DwcGzYsAF6vR6TJk2C1WrFgAEDkJOTg8ceewzvv/8+SktLUVhYiGXLlmHDhg1Bxba0tBR5eXkoKCjAuXPn8PHHH6OkpCTgIJGIiIjaF8ddHHe1hd27d+N3v/sdTp48iRUrVuC9997D/Pnz/ZZt7rPtTO0iCjVMEhJRl/Dcc881emxh8ODB+M///E+sWLEC6enpKCws9L7N7XZYLBbs2LEDycnJeOihhzB48GA8/vjjqKura/IbbovFgs2bN+PKlSu455578N3vfhcTJ07EH/7wh4DbJCcn43//93+xbt06pKen44033sALL7zgU2bYsGHYvn07Tp48ifHjx2PEiBF45plnkJSUFHC/Dz/8MLKzs/HNb34TPXr0wLvvvgtFUfDBBx8gJiYGEyZMQGZmJvr27Ys1a9b43UdERAQ++ugjiAgmT56MmpoarFy5Eo899hgWLlyIgQMHYurUqdi3b1/Qj99YLBZ8/vnnePjhhzFgwADMnDkTc+bMwaxZs4LanoiIiNoex10cd7W2hQsXYv/+/RgxYgR+85vf4KWXXkJWVpbfss19tp2pXUShRpGGE0sQEREREREREbWC1NRULFiwAAsWLOjoqhBRM3gnIRERERERERERkcYxSUhERERERERERKRxfNyYiIiIiIiIiIhI43gnIRERERERERERkcYxSUhERERERERERKRxTBISERERERERERFpHJOEREREREREREREGsckIRERERERERERkcYxSUhERERERERERKRxTBISERERERERERFpHJOEREREREREREREGsckIRERERERERERkcb9P/dyopTKjJl0AAAAAElFTkSuQmCC",
536
+ "text/plain": [
537
+ "<Figure size 1300x500 with 2 Axes>"
538
+ ]
539
+ },
540
+ "metadata": {},
541
+ "output_type": "display_data"
542
+ },
543
+ {
544
+ "name": "stdout",
545
+ "output_type": "stream",
546
+ "text": [
547
+ "💾 Guardado en reports/07_tokens_comparativa.png\n"
548
+ ]
549
+ }
550
+ ],
551
+ "source": [
552
+ "# Visualizacion: distribucion tokens antes vs despues\n",
553
+ "fig, axes = plt.subplots(1, 2, figsize=(13, 5))\n",
554
+ "\n",
555
+ "# Comparativa global\n",
556
+ "axes[0].hist(df['tokens_raw'], bins=40, alpha=0.6, label='Crudo',\n",
557
+ " color='#7F77DD', edgecolor='white')\n",
558
+ "axes[0].hist(df['tokens_clean'], bins=40, alpha=0.6, label='Limpio',\n",
559
+ " color='#5DCAA5', edgecolor='white')\n",
560
+ "axes[0].axvline(df['tokens_raw'].median(), color='#534AB7',\n",
561
+ " linestyle='--', lw=1.5, label=f'Mediana crudo: {df[\"tokens_raw\"].median():.0f}')\n",
562
+ "axes[0].axvline(df['tokens_clean'].median(), color='#0F6E56',\n",
563
+ " linestyle='--', lw=1.5, label=f'Mediana limpio: {df[\"tokens_clean\"].median():.0f}')\n",
564
+ "axes[0].set_title('Tokens: crudo vs limpio', fontweight='bold')\n",
565
+ "axes[0].set_xlabel('Numero de tokens')\n",
566
+ "axes[0].set_ylabel('Frecuencia')\n",
567
+ "axes[0].legend(fontsize=9)\n",
568
+ "\n",
569
+ "# Por clase\n",
570
+ "for clase, color in [('Toxico', '#E8593C'), ('No toxico', '#5DCAA5')]:\n",
571
+ " mask = df[TARGET_BIN] == (clase == 'Toxico')\n",
572
+ " axes[1].hist(df.loc[mask, 'tokens_clean'], bins=30, alpha=0.6,\n",
573
+ " label=clase, color=color, edgecolor='white')\n",
574
+ "axes[1].set_title('Tokens limpios por clase', fontweight='bold')\n",
575
+ "axes[1].set_xlabel('Numero de tokens limpios')\n",
576
+ "axes[1].legend()\n",
577
+ "\n",
578
+ "plt.tight_layout()\n",
579
+ "plt.savefig(PROJECT_ROOT / 'reports' / 'v2' / '07_tokens_comparativa.png',\n",
580
+ " dpi=150, bbox_inches='tight')\n",
581
+ "plt.show()\n",
582
+ "print('💾 Guardado en reports/07_tokens_comparativa.png')"
583
+ ]
584
+ },
585
+ {
586
+ "cell_type": "code",
587
+ "execution_count": 13,
588
+ "metadata": {},
589
+ "outputs": [
590
+ {
591
+ "name": "stdout",
592
+ "output_type": "stream",
593
+ "text": [
594
+ "Comentarios con < 3 tokens tras limpieza: 106\n",
595
+ "\n",
596
+ " [NO TOXICO]\n",
597
+ " CRUDO : I agree with the protestor.\n",
598
+ " LIMPIO : \"agree protestor\"\n",
599
+ "\n",
600
+ " [TOXICO]\n",
601
+ " CRUDO : I think ,he kill him\n",
602
+ " LIMPIO : \"think kill\"\n",
603
+ "\n",
604
+ " [NO TOXICO]\n",
605
+ " CRUDO : 1 word: Provocateur........\n",
606
+ " LIMPIO : \"word provocateur\"\n",
607
+ "\n",
608
+ " [NO TOXICO]\n",
609
+ " CRUDO : I LIKE TURTLES!\n",
610
+ " LIMPIO : \"turtle\"\n",
611
+ "\n",
612
+ " [TOXICO]\n",
613
+ " CRUDO : CNN is such Bullcrap!\n",
614
+ " LIMPIO : \"cnn bullcrap\"\n",
615
+ "\n",
616
+ " [TOXICO]\n",
617
+ " CRUDO : You all are some dumb as people\n",
618
+ " LIMPIO : \"dumb people\"\n",
619
+ "\n"
620
+ ]
621
+ }
622
+ ],
623
+ "source": [
624
+ "# Casos problematicos: comentarios con menos de 3 tokens tras limpieza\n",
625
+ "# Son comentarios que casi desaparecen -> hay que revisarlos\n",
626
+ "short_clean = df[df['tokens_clean'] < 3]\n",
627
+ "print(f'Comentarios con < 3 tokens tras limpieza: {len(short_clean)}')\n",
628
+ "print()\n",
629
+ "for _, row in short_clean.head(6).iterrows():\n",
630
+ " label = 'TOXICO' if row[TARGET_BIN] else 'NO TOXICO'\n",
631
+ " print(f' [{label}]')\n",
632
+ " print(f' CRUDO : {row[TEXT_COL][:80]}')\n",
633
+ " print(f' LIMPIO : \"{row[\"clean_text\"]}\"')\n",
634
+ " print()"
635
+ ]
636
+ },
637
+ {
638
+ "cell_type": "markdown",
639
+ "metadata": {},
640
+ "source": [
641
+ "## 4. Registro en MLflow\n",
642
+ "\n",
643
+ "Guardamos exactamente qué configuración usamos.\n",
644
+ "Si mañana cambiamos `min_token_length` o las custom stopwords,\n",
645
+ "podremos comparar qué configuración produjo mejores métricas al modelar.\n",
646
+ "\n",
647
+ "> Para ver el dashboard: `mlflow ui` desde la raíz del proyecto.\n",
648
+ "\n",
649
+ "> mlflow ui --backend-store-uri file:./mlruns --port 5001 "
650
+ ]
651
+ },
652
+ {
653
+ "cell_type": "code",
654
+ "execution_count": 14,
655
+ "metadata": {},
656
+ "outputs": [
657
+ {
658
+ "name": "stderr",
659
+ "output_type": "stream",
660
+ "text": [
661
+ "2026/05/14 16:23:55 INFO mlflow.tracking.fluent: Experiment with name 'Youtube_project_data' does not exist. Creating a new experiment.\n"
662
+ ]
663
+ },
664
+ {
665
+ "name": "stdout",
666
+ "output_type": "stream",
667
+ "text": [
668
+ "MLflow run registrado\n",
669
+ " Run ID : fe6b6538f89b490eb6f586ef555bc82f\n",
670
+ " Experimento: Youtube_project_data\n",
671
+ " Ver UI : mlflow ui --backend-store-uri file:///mnt/c/Users/under/Documents/F5/3_Projects/Project_9_Equipo3/Project_YT/mlruns\n"
672
+ ]
673
+ }
674
+ ],
675
+ "source": [
676
+ "# ── Configuración MLflow ──\n",
677
+ "MLFLOW_DIR = PROJECT_ROOT / 'mlruns'\n",
678
+ "EXPERIMENT_NAME = 'Youtube_project_data'\n",
679
+ "\n",
680
+ "mlflow.set_tracking_uri(f\"file:{MLFLOW_DIR}\")\n",
681
+ "mlflow.set_experiment(EXPERIMENT_NAME)\n",
682
+ "\n",
683
+ "\n",
684
+ "with mlflow.start_run(run_name='preprocessing_v2'):\n",
685
+ "\n",
686
+ " # Params: que configuracion usamos\n",
687
+ " mlflow.log_param('spacy_model', 'en_core_web_sm')\n",
688
+ " mlflow.log_param('nltk_stopwords', 'english')\n",
689
+ " mlflow.log_param('custom_stopwords', list(CUSTOM_STOPWORDS))\n",
690
+ " mlflow.log_param('min_token_length', MIN_TOKEN_LEN)\n",
691
+ " mlflow.log_param('remove_urls', prep_cfg['remove_urls'])\n",
692
+ " mlflow.log_param('remove_mentions', prep_cfg['remove_mentions'])\n",
693
+ " mlflow.log_param('lemmatize', prep_cfg['lemmatize'])\n",
694
+ "\n",
695
+ " # Metrics: que produce este preprocesamiento\n",
696
+ " reduction = (1 - df['tokens_clean'].mean() / df['tokens_raw'].mean()) * 100\n",
697
+ " mlflow.log_metric('avg_tokens_raw', round(df['tokens_raw'].mean(), 2))\n",
698
+ " mlflow.log_metric('avg_tokens_clean', round(df['tokens_clean'].mean(), 2))\n",
699
+ " mlflow.log_metric('median_tokens_clean', float(df['tokens_clean'].median()))\n",
700
+ " mlflow.log_metric('token_reduction_pct', round(reduction, 2))\n",
701
+ " mlflow.log_metric('empty_after_clean', int(empty_after))\n",
702
+ " mlflow.log_metric('short_texts_lt3', len(short_clean))\n",
703
+ "\n",
704
+ " # Artefactos: archivos que produce\n",
705
+ " out_csv = PROJECT_ROOT / 'data' / 'processed' / 'v2' /'comments_preprocessed.csv'\n",
706
+ " df[['CommentId', TEXT_COL, 'clean_text', TARGET_BIN] + SUBLABELS].to_csv(\n",
707
+ " out_csv, index=False\n",
708
+ " )\n",
709
+ " mlflow.log_artifact(str(out_csv))\n",
710
+ " mlflow.log_artifact(str(PROJECT_ROOT / 'reports' / 'v2' / '07_tokens_comparativa.png'))\n",
711
+ " mlflow.log_artifact(str(CONFIG_PATH))\n",
712
+ "\n",
713
+ " run_id = mlflow.active_run().info.run_id\n",
714
+ " print(f'MLflow run registrado')\n",
715
+ " print(f' Run ID : {run_id}')\n",
716
+ " print(f' Experimento: Youtube_project_data')\n",
717
+ " print(f' Ver UI : mlflow ui --backend-store-uri file://{MLFLOW_DIR}')"
718
+ ]
719
+ },
720
+ {
721
+ "cell_type": "markdown",
722
+ "metadata": {},
723
+ "source": [
724
+ "## 5. Conclusiones y decisiones"
725
+ ]
726
+ },
727
+ {
728
+ "cell_type": "code",
729
+ "execution_count": 15,
730
+ "metadata": {},
731
+ "outputs": [
732
+ {
733
+ "name": "stdout",
734
+ "output_type": "stream",
735
+ "text": [
736
+ "\n",
737
+ "CONCLUSIONES — PREPROCESAMIENTO\n",
738
+ "=======================================================\n",
739
+ "Pipeline aplicado:\n",
740
+ " 1. lowercase\n",
741
+ " 2. regex: URLs, @menciones, \\xa0, apostrofes, numeros\n",
742
+ " 3. spaCy: lematizacion (en_core_web_sm)\n",
743
+ " 4. NLTK: stopwords english + custom\n",
744
+ "\n",
745
+ "Resultado:\n",
746
+ " Reduccion tokens : 51.2%\n",
747
+ " Tokens mediana : 9 (antes: 19)\n",
748
+ " Comentarios vacios: 0\n",
749
+ "\n",
750
+ "Decisiones clave:\n",
751
+ " NO quitados: black, white, police, cop\n",
752
+ " -> EDA mostro que aparecen en ambas clases con contexto distinto\n",
753
+ " -> el modelo necesita verlas para aprender por bigrams\n",
754
+ " SI quitados: youtube, video, watch, like, comment, channel\n",
755
+ " -> ruido tematico sin valor discriminante\n",
756
+ " spaCy para lemma, no NLTK Stemmer\n",
757
+ " -> stemmer corta letras, lemma entiende gramatica\n",
758
+ "\n",
759
+ "\n"
760
+ ]
761
+ }
762
+ ],
763
+ "source": [
764
+ "reduction = (1 - df['tokens_clean'].mean() / df['tokens_raw'].mean()) * 100\n",
765
+ "\n",
766
+ "print(f\"\"\"\n",
767
+ "CONCLUSIONES — PREPROCESAMIENTO\n",
768
+ "{'='*55}\n",
769
+ "Pipeline aplicado:\n",
770
+ " 1. lowercase\n",
771
+ " 2. regex: URLs, @menciones, \\\\xa0, apostrofes, numeros\n",
772
+ " 3. spaCy: lematizacion (en_core_web_sm)\n",
773
+ " 4. NLTK: stopwords english + custom\n",
774
+ "\n",
775
+ "Resultado:\n",
776
+ " Reduccion tokens : {reduction:.1f}%\n",
777
+ " Tokens mediana : {df['tokens_clean'].median():.0f} (antes: {df['tokens_raw'].median():.0f})\n",
778
+ " Comentarios vacios: {empty_after}\n",
779
+ "\n",
780
+ "Decisiones clave:\n",
781
+ " NO quitados: black, white, police, cop\n",
782
+ " -> EDA mostro que aparecen en ambas clases con contexto distinto\n",
783
+ " -> el modelo necesita verlas para aprender por bigrams\n",
784
+ " SI quitados: youtube, video, watch, like, comment, channel\n",
785
+ " -> ruido tematico sin valor discriminante\n",
786
+ " spaCy para lemma, no NLTK Stemmer\n",
787
+ " -> stemmer corta letras, lemma entiende gramatica\n",
788
+ "\n",
789
+ "\"\"\")"
790
+ ]
791
+ }
792
+ ],
793
+ "metadata": {
794
+ "kernelspec": {
795
+ "display_name": "py310",
796
+ "language": "python",
797
+ "name": "python3"
798
+ },
799
+ "language_info": {
800
+ "codemirror_mode": {
801
+ "name": "ipython",
802
+ "version": 3
803
+ },
804
+ "file_extension": ".py",
805
+ "mimetype": "text/x-python",
806
+ "name": "python",
807
+ "nbconvert_exporter": "python",
808
+ "pygments_lexer": "ipython3",
809
+ "version": "3.10.20"
810
+ }
811
+ },
812
+ "nbformat": 4,
813
+ "nbformat_minor": 4
814
+ }
notebooks/03_vectorization_v2.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
notebooks/04_baseline_v2.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
notebooks/05_ensemble_v2.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
notebooks/06_tuning_clean_v2.ipynb ADDED
@@ -0,0 +1,956 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# ⚙️ Notebook 06 — Optimización con Optuna + LinearSVC\n",
8
+ "## YouTube Hate Speech Detection\n",
9
+ "\n",
10
+ "### ¿Qué hace este notebook?\n",
11
+ "Optimizamos LR y RF con Optuna, añadimos LinearSVC como modelo adicional,\n",
12
+ "y comparamos 5 modelos en una tabla unificada.\n",
13
+ "\n",
14
+ "### Nota metodológica — dos métricas de GAP\n",
15
+ "Este notebook reporta dos métricas de gap distintas:\n",
16
+ "\n",
17
+ "| Métrica | Fórmula | Qué mide |\n",
18
+ "|---|---|---|\n",
19
+ "| `train_test_gap` | f1_train - f1_test | Memorización / ajuste in-sample |\n",
20
+ "| `cv_test_gap` | \\|cv_mean - f1_test\\| | Estabilidad de generalización (OOS vs OOS) |\n",
21
+ "\n",
22
+ "La rúbrica exige < 5pp. El `cv_test_gap` es la comparación\n",
23
+ "estadísticamente correcta porque ambos términos son out-of-sample.\n",
24
+ "Ver informe metodológico para la justificación completa.\n",
25
+ "\n",
26
+ "### Modelos comparados\n",
27
+ "1. LR baseline (cargado desde disco)\n",
28
+ "2. RF baseline (cargado desde disco)\n",
29
+ "3. LinearSVC (nuevo — muy competitivo en TF-IDF sparse)\n",
30
+ "4. LR tuned (Optuna)\n",
31
+ "5. RF tuned (Optuna)\n",
32
+ "\n",
33
+ "### Sin hardcoding\n",
34
+ "Todos los valores se calculan en el momento desde los modelos en disco."
35
+ ]
36
+ },
37
+ {
38
+ "cell_type": "markdown",
39
+ "metadata": {},
40
+ "source": [
41
+ "## 0. Imports y configuración"
42
+ ]
43
+ },
44
+ {
45
+ "cell_type": "code",
46
+ "execution_count": 2,
47
+ "metadata": {},
48
+ "outputs": [
49
+ {
50
+ "name": "stdout",
51
+ "output_type": "stream",
52
+ "text": [
53
+ "PROJECT_ROOT: /mnt/c/Users/under/Documents/F5/3_Projects/Project_9_Equipo3/Project_YT\n"
54
+ ]
55
+ }
56
+ ],
57
+ "source": [
58
+ "import sys, yaml, joblib, warnings\n",
59
+ "import numpy as np\n",
60
+ "import pandas as pd\n",
61
+ "import matplotlib.pyplot as plt\n",
62
+ "import mlflow, mlflow.sklearn\n",
63
+ "import optuna\n",
64
+ "from pathlib import Path\n",
65
+ "from sklearn.svm import LinearSVC\n",
66
+ "from sklearn.calibration import CalibratedClassifierCV\n",
67
+ "from sklearn.ensemble import RandomForestClassifier\n",
68
+ "from sklearn.linear_model import LogisticRegression\n",
69
+ "from sklearn.feature_extraction.text import TfidfVectorizer\n",
70
+ "from sklearn.pipeline import Pipeline\n",
71
+ "from sklearn.model_selection import (\n",
72
+ " train_test_split, StratifiedKFold,\n",
73
+ " cross_val_score, cross_validate\n",
74
+ ")\n",
75
+ "from sklearn.metrics import f1_score, roc_auc_score, classification_report\n",
76
+ "warnings.filterwarnings('ignore')\n",
77
+ "optuna.logging.set_verbosity(optuna.logging.WARNING)\n",
78
+ "\n",
79
+ "PROJECT_ROOT = Path.cwd().parent\n",
80
+ "sys.path.insert(0, str(PROJECT_ROOT))\n",
81
+ "plt.rcParams['figure.figsize'] = (12, 5)\n",
82
+ "plt.rcParams['axes.spines.top'] = False\n",
83
+ "plt.rcParams['axes.spines.right'] = False\n",
84
+ "print(f'PROJECT_ROOT: {PROJECT_ROOT}')"
85
+ ]
86
+ },
87
+ {
88
+ "cell_type": "code",
89
+ "execution_count": 3,
90
+ "metadata": {},
91
+ "outputs": [
92
+ {
93
+ "name": "stdout",
94
+ "output_type": "stream",
95
+ "text": [
96
+ "LR baseline: True | RF baseline: True\n"
97
+ ]
98
+ }
99
+ ],
100
+ "source": [
101
+ "CONFIG_FEAT = PROJECT_ROOT / 'configs' / 'features.yaml'\n",
102
+ "CONFIG_PIPE = PROJECT_ROOT / 'configs' / 'pipeline.yaml'\n",
103
+ "CONFIG_MOD = PROJECT_ROOT / 'configs' / 'models.yaml'\n",
104
+ "\n",
105
+ "with open(CONFIG_FEAT) as f: feat_cfg = yaml.safe_load(f)\n",
106
+ "with open(CONFIG_PIPE) as f: pipe_cfg = yaml.safe_load(f)\n",
107
+ "with open(CONFIG_MOD) as f: mod_cfg = yaml.safe_load(f)\n",
108
+ "\n",
109
+ "tfidf_cfg = feat_cfg['vectorization']['tfidf']\n",
110
+ "rf_cfg = mod_cfg['models']['random_forest']\n",
111
+ "lr_cfg = mod_cfg['models']['logistic_regression']\n",
112
+ "TARGET = pipe_cfg['data']['target_binary']\n",
113
+ "RAND = pipe_cfg['pipeline']['random_state']\n",
114
+ "TEST_SIZE = pipe_cfg['pipeline']['test_size']\n",
115
+ "CV_FOLDS = pipe_cfg['pipeline']['cv_folds']\n",
116
+ "\n",
117
+ "MODEL_LR = PROJECT_ROOT / 'models' / 'lr_baseline.joblib'\n",
118
+ "MODEL_RF = PROJECT_ROOT / 'models' / 'best_ensemble.joblib'\n",
119
+ "print(f'LR baseline: {MODEL_LR.exists()} | RF baseline: {MODEL_RF.exists()}')"
120
+ ]
121
+ },
122
+ {
123
+ "cell_type": "markdown",
124
+ "metadata": {},
125
+ "source": [
126
+ "## 1. Carga de datos y split\n",
127
+ "\n",
128
+ "Mismo `random_state` fijo en todos los notebooks — comparación justa."
129
+ ]
130
+ },
131
+ {
132
+ "cell_type": "code",
133
+ "execution_count": 4,
134
+ "metadata": {},
135
+ "outputs": [
136
+ {
137
+ "name": "stdout",
138
+ "output_type": "stream",
139
+ "text": [
140
+ "Train: 800 | Test: 200\n"
141
+ ]
142
+ }
143
+ ],
144
+ "source": [
145
+ "PROCESSED = PROJECT_ROOT / 'data' / 'processed' / 'v2' / 'comments_preprocessed.csv'\n",
146
+ "df = pd.read_csv(PROCESSED)\n",
147
+ "df['clean_text'] = df['clean_text'].fillna('').astype(str)\n",
148
+ "X, y = df['clean_text'], df[TARGET]\n",
149
+ "\n",
150
+ "X_train, X_test, y_train, y_test = train_test_split(\n",
151
+ " X, y, test_size=TEST_SIZE, random_state=RAND, stratify=y\n",
152
+ ")\n",
153
+ "cv_strategy = StratifiedKFold(n_splits=CV_FOLDS, shuffle=True, random_state=RAND)\n",
154
+ "print(f'Train: {len(X_train)} | Test: {len(X_test)}')"
155
+ ]
156
+ },
157
+ {
158
+ "cell_type": "markdown",
159
+ "metadata": {},
160
+ "source": [
161
+ "## 2. Función de evaluación\n",
162
+ "\n",
163
+ "Calcula ambas métricas de gap. `cv_scores` es opcional —\n",
164
+ "solo los modelos evaluados con `cross_validate` tendrán `cv_test_gap`."
165
+ ]
166
+ },
167
+ {
168
+ "cell_type": "code",
169
+ "execution_count": 5,
170
+ "metadata": {},
171
+ "outputs": [],
172
+ "source": [
173
+ "def evaluate_pipeline(pipeline, X_tr, y_tr, X_te, y_te, name, cv_scores=None):\n",
174
+ " \"\"\"\n",
175
+ " Evalua un pipeline y devuelve dict con ambas metricas de gap.\n",
176
+ "\n",
177
+ " train_test_gap : f1_train - f1_test (in-sample vs out-of-sample)\n",
178
+ " cv_test_gap : |cv_mean - f1_test| (OOS vs OOS — para rubrica)\n",
179
+ " \"\"\"\n",
180
+ " y_pred = pipeline.predict(X_te)\n",
181
+ " y_pred_proba = pipeline.predict_proba(X_te)[:, 1]\n",
182
+ " y_pred_train = pipeline.predict(X_tr)\n",
183
+ "\n",
184
+ " f1_te = f1_score(y_te, y_pred, average='weighted')\n",
185
+ " f1_tr = f1_score(y_tr, y_pred_train, average='weighted')\n",
186
+ " roc = roc_auc_score(y_te, y_pred_proba)\n",
187
+ "\n",
188
+ " cv_mean = cv_std = cv_test_gap = None\n",
189
+ " if cv_scores is not None:\n",
190
+ " cv_mean = cv_scores['test_score'].mean()\n",
191
+ " cv_std = cv_scores['test_score'].std()\n",
192
+ " cv_test_gap = abs(cv_mean - f1_te) * 100\n",
193
+ "\n",
194
+ " return {\n",
195
+ " 'name' : name,\n",
196
+ " 'f1_test' : round(f1_te, 4),\n",
197
+ " 'f1_train' : round(f1_tr, 4),\n",
198
+ " 'train_test_gap_pp': round((f1_tr - f1_te) * 100, 2),\n",
199
+ " 'cv_mean' : round(cv_mean, 4) if cv_mean is not None else None,\n",
200
+ " 'cv_std' : round(cv_std, 4) if cv_std is not None else None,\n",
201
+ " 'cv_test_gap_pp' : round(cv_test_gap, 2) if cv_test_gap is not None else None,\n",
202
+ " 'roc_auc' : round(roc, 4),\n",
203
+ " 'fp' : int(((y_te == False) & (y_pred == True)).sum()),\n",
204
+ " 'fn' : int(((y_te == True) & (y_pred == False)).sum()),\n",
205
+ " }"
206
+ ]
207
+ },
208
+ {
209
+ "cell_type": "markdown",
210
+ "metadata": {},
211
+ "source": [
212
+ "## 3. Helper TF-IDF y carga de baselines\n",
213
+ "\n",
214
+ "Los baselines se cargan desde disco y se evalúan sin re-entrenar."
215
+ ]
216
+ },
217
+ {
218
+ "cell_type": "code",
219
+ "execution_count": 6,
220
+ "metadata": {},
221
+ "outputs": [
222
+ {
223
+ "name": "stdout",
224
+ "output_type": "stream",
225
+ "text": [
226
+ " LR baseline F1=0.7531 | train-test=10.91pp | cv-test=5.17pp\n",
227
+ " RF baseline F1=0.7531 | train-test=10.91pp | cv-test=5.17pp\n"
228
+ ]
229
+ }
230
+ ],
231
+ "source": [
232
+ "def make_tfidf(**overrides):\n",
233
+ " params = {\n",
234
+ " 'max_features': tfidf_cfg['max_features'],\n",
235
+ " 'ngram_range' : tuple(tfidf_cfg['ngram_range']),\n",
236
+ " 'sublinear_tf': tfidf_cfg['sublinear_tf'],\n",
237
+ " 'min_df' : tfidf_cfg['min_df'],\n",
238
+ " 'analyzer' : 'word',\n",
239
+ " 'strip_accents': 'unicode',\n",
240
+ " }\n",
241
+ " params.update(overrides)\n",
242
+ " return TfidfVectorizer(**params)\n",
243
+ "\n",
244
+ "# Cargar baselines desde disco\n",
245
+ "lr_baseline_pipe = joblib.load(MODEL_LR)\n",
246
+ "rf_baseline_pipe = joblib.load(MODEL_RF)\n",
247
+ "\n",
248
+ "# CV scores para baselines\n",
249
+ "cv_lr_base = cross_validate(lr_baseline_pipe, X_train, y_train,\n",
250
+ " cv=cv_strategy, scoring='f1_weighted',\n",
251
+ " return_train_score=False, n_jobs=-1)\n",
252
+ "cv_rf_base = cross_validate(rf_baseline_pipe, X_train, y_train,\n",
253
+ " cv=cv_strategy, scoring='f1_weighted',\n",
254
+ " return_train_score=False, n_jobs=-1)\n",
255
+ "\n",
256
+ "metrics_lr_base = evaluate_pipeline(lr_baseline_pipe, X_train, y_train,\n",
257
+ " X_test, y_test, 'LR baseline', cv_lr_base)\n",
258
+ "metrics_rf_base = evaluate_pipeline(rf_baseline_pipe, X_train, y_train,\n",
259
+ " X_test, y_test, 'RF baseline', cv_rf_base)\n",
260
+ "\n",
261
+ "for m in [metrics_lr_base, metrics_rf_base]:\n",
262
+ " print(f\" {m['name']:15} F1={m['f1_test']:.4f} | \"\n",
263
+ " f\"train-test={m['train_test_gap_pp']}pp | \"\n",
264
+ " f\"cv-test={m['cv_test_gap_pp']}pp\")"
265
+ ]
266
+ },
267
+ {
268
+ "cell_type": "markdown",
269
+ "metadata": {},
270
+ "source": [
271
+ "## 4. LinearSVC — modelo adicional\n",
272
+ "\n",
273
+ "### ¿Por qué LinearSVC en TF-IDF?\n",
274
+ "LinearSVC usa un hiperplano de separación lineal que funciona\n",
275
+ "excepcionalmente bien con matrices sparse de alta dimensión.\n",
276
+ "No tiene probabilidades nativas, pero `CalibratedClassifierCV`\n",
277
+ "las añade mediante Platt scaling.\n",
278
+ "\n",
279
+ "Frecuentemente supera a LR en clasificación de texto porque\n",
280
+ "maximiza el margen entre clases en lugar de minimizar log-loss."
281
+ ]
282
+ },
283
+ {
284
+ "cell_type": "code",
285
+ "execution_count": 7,
286
+ "metadata": {},
287
+ "outputs": [
288
+ {
289
+ "name": "stdout",
290
+ "output_type": "stream",
291
+ "text": [
292
+ " LinearSVC F1=0.7250 | train-test=23.99pp | cv-test=4.03pp\n"
293
+ ]
294
+ }
295
+ ],
296
+ "source": [
297
+ "# LinearSVC con calibración para obtener predict_proba\n",
298
+ "svc_pipeline = Pipeline([\n",
299
+ " ('tfidf', make_tfidf()),\n",
300
+ " ('clf', CalibratedClassifierCV(\n",
301
+ " LinearSVC(\n",
302
+ " C=1.0, max_iter=2000,\n",
303
+ " class_weight='balanced',\n",
304
+ " random_state=RAND,\n",
305
+ " ),\n",
306
+ " cv=3\n",
307
+ " ))\n",
308
+ "])\n",
309
+ "\n",
310
+ "svc_pipeline.fit(X_train, y_train)\n",
311
+ "\n",
312
+ "cv_svc = cross_validate(svc_pipeline, X_train, y_train,\n",
313
+ " cv=cv_strategy, scoring='f1_weighted',\n",
314
+ " return_train_score=False, n_jobs=-1)\n",
315
+ "\n",
316
+ "metrics_svc = evaluate_pipeline(svc_pipeline, X_train, y_train,\n",
317
+ " X_test, y_test, 'LinearSVC', cv_svc)\n",
318
+ "\n",
319
+ "print(f\" LinearSVC F1={metrics_svc['f1_test']:.4f} | \"\n",
320
+ " f\"train-test={metrics_svc['train_test_gap_pp']}pp | \"\n",
321
+ " f\"cv-test={metrics_svc['cv_test_gap_pp']}pp\")"
322
+ ]
323
+ },
324
+ {
325
+ "cell_type": "markdown",
326
+ "metadata": {},
327
+ "source": [
328
+ "## 5. Optuna — Logistic Regression\n",
329
+ "\n",
330
+ "Búsqueda bayesiana sobre el pipeline completo TF-IDF + LR.\n",
331
+ "Optuna solo ve X_train — X_test se reserva para evaluación final."
332
+ ]
333
+ },
334
+ {
335
+ "cell_type": "code",
336
+ "execution_count": 8,
337
+ "metadata": {},
338
+ "outputs": [
339
+ {
340
+ "name": "stdout",
341
+ "output_type": "stream",
342
+ "text": [
343
+ "Optimizando LR — 60 trials...\n"
344
+ ]
345
+ },
346
+ {
347
+ "name": "stderr",
348
+ "output_type": "stream",
349
+ "text": [
350
+ "Best trial: 52. Best value: 0.710353: 100%|██████████| 60/60 [00:04<00:00, 14.23it/s]"
351
+ ]
352
+ },
353
+ {
354
+ "name": "stdout",
355
+ "output_type": "stream",
356
+ "text": [
357
+ "Mejor F1 CV: 0.7104\n",
358
+ "Params : {'ngram_range': '1_2', 'max_features': 4045, 'min_df': 2, 'sublinear_tf': False, 'C': 0.3235215031170205}\n"
359
+ ]
360
+ },
361
+ {
362
+ "name": "stderr",
363
+ "output_type": "stream",
364
+ "text": [
365
+ "\n"
366
+ ]
367
+ }
368
+ ],
369
+ "source": [
370
+ "N_TRIALS = 60\n",
371
+ "\n",
372
+ "def objective_lr(trial):\n",
373
+ " ngram_str = trial.suggest_categorical('ngram_range', ['1_1', '1_2'])\n",
374
+ " ngram = (1,1) if ngram_str == '1_1' else (1,2)\n",
375
+ " pipe = Pipeline([\n",
376
+ " ('tfidf', make_tfidf(\n",
377
+ " max_features = trial.suggest_int('max_features', 500, 5000),\n",
378
+ " min_df = trial.suggest_int('min_df', 2, 6),\n",
379
+ " ngram_range = ngram,\n",
380
+ " sublinear_tf = trial.suggest_categorical('sublinear_tf', [True, False]),\n",
381
+ " )),\n",
382
+ " ('clf', LogisticRegression(\n",
383
+ " C = trial.suggest_float('C', 0.01, 2.0, log=True),\n",
384
+ " max_iter = 1000,\n",
385
+ " class_weight = 'balanced',\n",
386
+ " solver = 'lbfgs',\n",
387
+ " random_state = RAND,\n",
388
+ " ))\n",
389
+ " ])\n",
390
+ " return cross_val_score(pipe, X_train, y_train,\n",
391
+ " cv=cv_strategy, scoring='f1_weighted',\n",
392
+ " n_jobs=-1).mean()\n",
393
+ "\n",
394
+ "study_lr = optuna.create_study(direction='maximize',\n",
395
+ " sampler=optuna.samplers.TPESampler(seed=RAND),\n",
396
+ " study_name='lr_optimization')\n",
397
+ "print(f'Optimizando LR — {N_TRIALS} trials...')\n",
398
+ "study_lr.optimize(objective_lr, n_trials=N_TRIALS, show_progress_bar=True)\n",
399
+ "print(f'Mejor F1 CV: {study_lr.best_value:.4f}')\n",
400
+ "print(f'Params : {study_lr.best_trial.params}')"
401
+ ]
402
+ },
403
+ {
404
+ "cell_type": "code",
405
+ "execution_count": 9,
406
+ "metadata": {},
407
+ "outputs": [
408
+ {
409
+ "name": "stdout",
410
+ "output_type": "stream",
411
+ "text": [
412
+ "LR tuned F1=0.7579 | train-test=14.07pp | cv-test=4.76pp\n"
413
+ ]
414
+ }
415
+ ],
416
+ "source": [
417
+ "bp_lr = study_lr.best_trial.params\n",
418
+ "ngram_lr = (1,1) if bp_lr['ngram_range'] == '1_1' else (1,2)\n",
419
+ "\n",
420
+ "lr_tuned_pipe = Pipeline([\n",
421
+ " ('tfidf', make_tfidf(\n",
422
+ " max_features=bp_lr['max_features'], min_df=bp_lr['min_df'],\n",
423
+ " ngram_range=ngram_lr, sublinear_tf=bp_lr['sublinear_tf'],\n",
424
+ " )),\n",
425
+ " ('clf', LogisticRegression(\n",
426
+ " C=bp_lr['C'], max_iter=1000,\n",
427
+ " class_weight='balanced', solver='lbfgs', random_state=RAND,\n",
428
+ " ))\n",
429
+ "])\n",
430
+ "lr_tuned_pipe.fit(X_train, y_train)\n",
431
+ "\n",
432
+ "cv_lr_tuned = cross_validate(lr_tuned_pipe, X_train, y_train,\n",
433
+ " cv=cv_strategy, scoring='f1_weighted',\n",
434
+ " return_train_score=False, n_jobs=-1)\n",
435
+ "\n",
436
+ "metrics_lr_tuned = evaluate_pipeline(lr_tuned_pipe, X_train, y_train,\n",
437
+ " X_test, y_test, 'LR tuned', cv_lr_tuned)\n",
438
+ "print(f\"LR tuned F1={metrics_lr_tuned['f1_test']:.4f} | \"\n",
439
+ " f\"train-test={metrics_lr_tuned['train_test_gap_pp']}pp | \"\n",
440
+ " f\"cv-test={metrics_lr_tuned['cv_test_gap_pp']}pp\")"
441
+ ]
442
+ },
443
+ {
444
+ "cell_type": "markdown",
445
+ "metadata": {},
446
+ "source": [
447
+ "## 6. Optuna — Random Forest\n",
448
+ "\n",
449
+ "Búsqueda sobre n_estimators, max_depth y min_samples_leaf.\n",
450
+ "RF con bigramas es muy lento — usamos solo unigramas."
451
+ ]
452
+ },
453
+ {
454
+ "cell_type": "code",
455
+ "execution_count": 10,
456
+ "metadata": {},
457
+ "outputs": [
458
+ {
459
+ "name": "stdout",
460
+ "output_type": "stream",
461
+ "text": [
462
+ "Optimizando RF — 60 trials...\n"
463
+ ]
464
+ },
465
+ {
466
+ "name": "stderr",
467
+ "output_type": "stream",
468
+ "text": [
469
+ "Best trial: 37. Best value: 0.713928: 100%|██████████| 60/60 [00:54<00:00, 1.09it/s]"
470
+ ]
471
+ },
472
+ {
473
+ "name": "stdout",
474
+ "output_type": "stream",
475
+ "text": [
476
+ "Mejor F1 CV: 0.7139\n",
477
+ "Params : {'max_features': 1380, 'min_df': 6, 'n_estimators': 171, 'max_depth': 9, 'min_samples_leaf': 2}\n"
478
+ ]
479
+ },
480
+ {
481
+ "name": "stderr",
482
+ "output_type": "stream",
483
+ "text": [
484
+ "\n"
485
+ ]
486
+ }
487
+ ],
488
+ "source": [
489
+ "def objective_rf(trial):\n",
490
+ " pipe = Pipeline([\n",
491
+ " ('tfidf', make_tfidf(\n",
492
+ " max_features = trial.suggest_int('max_features', 500, 5000),\n",
493
+ " min_df = trial.suggest_int('min_df', 2, 6),\n",
494
+ " ngram_range = (1, 1),\n",
495
+ " sublinear_tf = True,\n",
496
+ " )),\n",
497
+ " ('clf', RandomForestClassifier(\n",
498
+ " n_estimators = trial.suggest_int('n_estimators', 50, 300),\n",
499
+ " max_depth = trial.suggest_int('max_depth', 4, 12),\n",
500
+ " min_samples_leaf = trial.suggest_int('min_samples_leaf', 2, 8),\n",
501
+ " max_features = 'sqrt',\n",
502
+ " class_weight = 'balanced',\n",
503
+ " random_state = RAND, n_jobs=-1,\n",
504
+ " ))\n",
505
+ " ])\n",
506
+ " return cross_val_score(pipe, X_train, y_train,\n",
507
+ " cv=cv_strategy, scoring='f1_weighted',\n",
508
+ " n_jobs=-1).mean()\n",
509
+ "\n",
510
+ "study_rf = optuna.create_study(direction='maximize',\n",
511
+ " sampler=optuna.samplers.TPESampler(seed=RAND),\n",
512
+ " study_name='rf_optimization')\n",
513
+ "print(f'Optimizando RF — {N_TRIALS} trials...')\n",
514
+ "study_rf.optimize(objective_rf, n_trials=N_TRIALS, show_progress_bar=True)\n",
515
+ "print(f'Mejor F1 CV: {study_rf.best_value:.4f}')\n",
516
+ "print(f'Params : {study_rf.best_trial.params}')"
517
+ ]
518
+ },
519
+ {
520
+ "cell_type": "code",
521
+ "execution_count": 11,
522
+ "metadata": {},
523
+ "outputs": [
524
+ {
525
+ "name": "stdout",
526
+ "output_type": "stream",
527
+ "text": [
528
+ "RF tuned F1=0.6924 | train-test=12.09pp | cv-test=2.15pp\n"
529
+ ]
530
+ }
531
+ ],
532
+ "source": [
533
+ "bp_rf = study_rf.best_trial.params\n",
534
+ "\n",
535
+ "rf_tuned_pipe = Pipeline([\n",
536
+ " ('tfidf', make_tfidf(\n",
537
+ " max_features=bp_rf['max_features'], min_df=bp_rf['min_df'],\n",
538
+ " ngram_range=(1,1), sublinear_tf=True,\n",
539
+ " )),\n",
540
+ " ('clf', RandomForestClassifier(\n",
541
+ " n_estimators=bp_rf['n_estimators'], max_depth=bp_rf['max_depth'],\n",
542
+ " min_samples_leaf=bp_rf['min_samples_leaf'],\n",
543
+ " max_features='sqrt', class_weight='balanced',\n",
544
+ " random_state=RAND, n_jobs=-1,\n",
545
+ " ))\n",
546
+ "])\n",
547
+ "rf_tuned_pipe.fit(X_train, y_train)\n",
548
+ "\n",
549
+ "cv_rf_tuned = cross_validate(rf_tuned_pipe, X_train, y_train,\n",
550
+ " cv=cv_strategy, scoring='f1_weighted',\n",
551
+ " return_train_score=False, n_jobs=-1)\n",
552
+ "\n",
553
+ "metrics_rf_tuned = evaluate_pipeline(rf_tuned_pipe, X_train, y_train,\n",
554
+ " X_test, y_test, 'RF tuned', cv_rf_tuned)\n",
555
+ "print(f\"RF tuned F1={metrics_rf_tuned['f1_test']:.4f} | \"\n",
556
+ " f\"train-test={metrics_rf_tuned['train_test_gap_pp']}pp | \"\n",
557
+ " f\"cv-test={metrics_rf_tuned['cv_test_gap_pp']}pp\")"
558
+ ]
559
+ },
560
+ {
561
+ "cell_type": "markdown",
562
+ "metadata": {},
563
+ "source": [
564
+ "## 7. Tabla comparativa — 5 modelos\n",
565
+ "\n",
566
+ "Se reportan ambos gaps. La columna `cv_test_gap` es la referencia\n",
567
+ "para la rúbrica (OOS vs OOS). La columna `train_test_gap` se mantiene\n",
568
+ "para análisis de memorización."
569
+ ]
570
+ },
571
+ {
572
+ "cell_type": "code",
573
+ "execution_count": 12,
574
+ "metadata": {},
575
+ "outputs": [
576
+ {
577
+ "name": "stdout",
578
+ "output_type": "stream",
579
+ "text": [
580
+ "COMPARATIVA FINAL — 5 MODELOS\n",
581
+ "====================================================================================================\n",
582
+ "Modelo F1 Test F1 Train TrTe gap CV Mean CV Std CV-Te gap FP FN Rubrica\n",
583
+ "----------------------------------------------------------------------------------------------------\n",
584
+ " LR baseline 0.7531 0.8623 10.91 0.7015 0.0312 5.17 19 30 ⚠️ 5.2pp\n",
585
+ " LR tuned 0.7579 0.8987 14.07 0.7104 0.0353 4.76 18 30 ✅ OK\n",
586
+ " RF baseline 0.7531 0.8623 10.91 0.7015 0.0312 5.17 19 30 ⚠️ 5.2pp\n",
587
+ " RF tuned 0.6924 0.8133 12.09 0.7139 0.0334 2.15 9 49 ✅ OK\n",
588
+ " LinearSVC 0.7250 0.9649 23.99 0.6847 0.0276 4.03 17 37 ✅ OK\n",
589
+ "\n",
590
+ "GANADOR (F1 test): LR tuned\n",
591
+ " F1 test : 0.7579\n",
592
+ " cv_test_gap : 4.76pp\n",
593
+ " train_test_gap: 14.07pp\n"
594
+ ]
595
+ }
596
+ ],
597
+ "source": [
598
+ "all_metrics = [\n",
599
+ " metrics_lr_base, metrics_lr_tuned,\n",
600
+ " metrics_rf_base, metrics_rf_tuned,\n",
601
+ " metrics_svc\n",
602
+ "]\n",
603
+ "\n",
604
+ "comp_df = pd.DataFrame(all_metrics).set_index('name')\n",
605
+ "\n",
606
+ "# Rubrica basada en cv_test_gap\n",
607
+ "comp_df['rubrica'] = comp_df['cv_test_gap_pp'].apply(\n",
608
+ " lambda x: '✅ OK' if x is not None and x < 5 else f'⚠️ {x:.1f}pp' if x else 'N/A'\n",
609
+ ")\n",
610
+ "\n",
611
+ "print('COMPARATIVA FINAL — 5 MODELOS')\n",
612
+ "print('=' * 100)\n",
613
+ "print(f\"{'Modelo':16} {'F1 Test':>9} {'F1 Train':>9} \"\n",
614
+ " f\"{'TrTe gap':>9} {'CV Mean':>9} {'CV Std':>8} \"\n",
615
+ " f\"{'CV-Te gap':>10} {'FP':>4} {'FN':>4} {'Rubrica':>12}\")\n",
616
+ "print('-' * 100)\n",
617
+ "for name, row in comp_df.iterrows():\n",
618
+ " cv_gap_str = f\"{row['cv_test_gap_pp']:.2f}\" if row['cv_test_gap_pp'] else 'N/A'\n",
619
+ " cv_mean_str = f\"{row['cv_mean']:.4f}\" if row['cv_mean'] else 'N/A'\n",
620
+ " cv_std_str = f\"{row['cv_std']:.4f}\" if row['cv_std'] else 'N/A'\n",
621
+ " print(f\" {name:14} {row['f1_test']:>9.4f} {row['f1_train']:>9.4f} \"\n",
622
+ " f\"{row['train_test_gap_pp']:>9.2f} {cv_mean_str:>9} {cv_std_str:>8} \"\n",
623
+ " f\"{cv_gap_str:>10} {row['fp']:>4} {row['fn']:>4} {row['rubrica']:>12}\")\n",
624
+ "\n",
625
+ "best_name = comp_df['f1_test'].idxmax()\n",
626
+ "print(f'\\nGANADOR (F1 test): {best_name}')\n",
627
+ "print(f\" F1 test : {comp_df.loc[best_name, 'f1_test']:.4f}\")\n",
628
+ "print(f\" cv_test_gap : {comp_df.loc[best_name, 'cv_test_gap_pp']:.2f}pp\")\n",
629
+ "print(f\" train_test_gap: {comp_df.loc[best_name, 'train_test_gap_pp']:.2f}pp\")"
630
+ ]
631
+ },
632
+ {
633
+ "cell_type": "code",
634
+ "execution_count": 13,
635
+ "metadata": {},
636
+ "outputs": [
637
+ {
638
+ "data": {
639
+ "image/png": "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Yr788kulpqZKkqZMmaJ169bpk08+0fPPP6/atWtn+4ZumXnttde0bt06SVLz5s21bNkyffbZZ6pZs6Ykafr06frpp58kSWvXrtWVK1ckSU8++aTWrVunzz77TC+++KIaN24sm81mDmmRfkzCJUuWaNOmTdq0aZPKlSt3wzkDAAqOuLg4u3FsW7duneU6s2fP1saNGyVJ4eHhWrRokT7++GNVqlRJkrRx40bNmjXL4bqenp5atmyZeQMnSZo2bZqaNGmiL7/8Ug888ICktKEJ3n33XYfb2L9/vyZPnqyvvvpKHTp0kCQlJydrxIgRZkzTpk01Z84crVixQuvXr9fatWv18ssvS0r72a2z/G6kjZLT79hy5crp9ttvlyRt2bJFJ06ckJT28981a9ZIkurWrat69erd0LFdk5KSohEjRsgwDEnSgw8+qJUrV2r48OFO73PQo0cPzZ8/XytXrlRUVJRWrlypPn36SJL27Nmjzz//3OF618abXbJkiapWrSpJOn78uKZMmWIe79mzZyVJPXv21Nq1a7V8+XK9+uqratu2rTw9PTM9liNHjig+Pl6SdNNNN2Uam1Uu10tOTtbixYu1cOFClS5dWpK0e/du/ec//7GLS7/fP/74I9Mc8oqvr6+6d+8uSTp27Jh+/PFHc9nSpUslSaVKlTKH33ruuef066+/SpI6d+5svt/DwsIkSSNHjnR4Y7UTJ06oYsWK+u9//6tVq1apa9euunjxosaPH2/GPPHEE1q5cqV69OihPXv2ZNjG8ePHNWzYMKWmpiowMFBz5szR119/rf79+0uS9u7dm2F4t/x08OBBNW7cWEuWLNHy5cuzdU3MzJ49e8zP2MiRI835EydO1Llz5zLEHzhwQB06dNDy5cv16aef6pZbbnG67eeee84cXq1kyZKaNWuW1qxZozlz5qhWrVpm3I1eMwzDUJ8+fXThwgVJUp06dfThhx9q5cqVGj9+vEqVKiUp7Zo1YMAAJScnS5IGDx6sVatWaciQIZLSPlMDBgwwP7OuHLej997ly5fVt29fJSYmysvLSy+99JK++eYbPfPMM5KkU6dOmfvO7us1Z84cvf766+Y6nTp1Mq/hzz33XK68nshH7q4aA8D1svqvs7Pet/nZ07Zly5YO1//yyy+NO++80yhdurTh6emZIfdrPUyy09P2vvvuM+d/8skn5vwR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81NCQoK2bt2qESNGqEOHDvrkk08cDl/QvXt3jRkzRomJiZo2bZpsNptatGihy5cv6+DBg1q/fr2uXLlil78jEydO1MSJE7P92tyIkJAQ87O5f/9+DRo0SDVr1tSrr76a5bqHDx9W37591atXL61bt878NYOvr686duyY45w8PDzUq1cvzZ07V1La3ynjxo1TrVq1dODAAa1YsUKrVq3KlWtGZGSk6tSpo99++00xMTG644479Mwzz6hkyZLavn27Ll68qBkzZuiuu+4yX6dt27Zp2LBhuvvuu7Vq1SqzgF+6dGm7XynciDvvvFNlypTR2bNn9cEHH6hkyZK68847lZKSokOHDun777/Xrl279Mcff2T79ZLsr+GbN2/W6tWrFRgYqBo1auTK64l8lEdj5QJAjqW/mYazm46ll/4GDY6m6wfbz0x2b0SW/sYW1yxZsiTDvv38/OwGrb92A4Ps3Igs/T6yGkzekRMnTmS40cju3bvtYl544QWnr5uHh4exefPmbO3LMJzfiCwxMdG44447Mj1H1+I//PDDTOM+/vhjc7tz5szJsPz6cwYAKDp+//13o2rVqpl+j+zYscMwDMNISEgwWrdu7TSuTZs2RmJiorltRzfqMQzD4fdPdr7j098I7drk5eVlthMMwzDuvvvuDDHpbwCUfp+52UYxjMy/YzO7oZFhGMbgwYPt1nvggQcyxLhybM4sXbrUsNlsGbZTt27dDK9FbGysUa5cuUz3mb59lb5N4+hclStXzryR0aZNmzJ9z02dOjXLY0n/em/bts1umSu5GIb9e9VRfJ06dYwrV644PB9hYWF2N5xyl5dfftku54YNG2aIuXjxot25djRl1e5Ob9q0aRnW9/DwsLumpP+MvPPOO4aHh4fTfbvy90duyM7fCmPHjnX4/gkJCclwfUu/vapVqzo81hdffNGMT39DrgkTJmTYt7NzEB0d7fB9ev11IDeuGdu3b7c71vRT+tds+fLlhre3t8M4b29v44svvrjh405v5cqVDm+O6OjYsvt6JScnG2FhYRlirl0Tc+P1RP5geAQAyCXdu3fX22+/rerVq8vPz09NmjTRmjVrVKdOHbfkU65cOd1+++3m83r16qlu3bp2MZ07d9bjjz+uOnXqqESJEvL09FTJkiV111136euvv86V3hY+Pj5as2aNXn/9dTVt2lSBgYHy8/NTlSpVdPfdd+u9997T/fffLynt5iFPPvmkGjZsqNKlS8vT01PBwcFq3bq1Fi9erIcfftjc7uOPP67Ro0erUqVK2b7BBwCg8Kpdu7Z2796tmTNnqlWrVipZsqR8fHwUHh6uDh066P333zd/+u3r66u1a9dq2rRpqlevnooVKyY/Pz/VrVtXU6dO1TfffCMfH588y3XkyJF64403VK1aNfn4+KhBgwb66quv7HoFfvjhh+rbt69Kly6tkJAQPfroo/ryyy9ztD9X2yg38h2bvkeko+dS7hzbAw88oE8//VS1a9eWj4+Pbr75Zi1atEh33HGHGePv7y8pbbzdtWvX6vbbb1dAQIAqVKigyZMna/LkyVnu5+OPP9bw4cNVpkwZFStWTJ06ddLGjRvNGxnVqFFDo0ePVvPmzRUaGiovLy8FBASoSZMmmjt3rkaPHp3lPnr16mX+NDmz4RSyyuV669at06OPPqrg4GAFBgbq4Ycf1rfffis/Pz8z5tKlS+bPrB955BG7Xt7u0qtXL7v3naP3UEhIiLZs2aIXXnhBkZGRKlasmPz9/VW9enV1795dH3/8sZo3b57tfY4ePVqvvfaaIiIi5Ovrq/r16+uLL75Q69atHcY/9thj2rhxox544AHzvIeGhqpp06YaN26c3nzzTdcPPI+NHz9e//73vxUSEqLixYvrvvvu0/fff5/hZo7Xa926tVasWKEGDRrI19dXlStX1owZM+x6b+dUcHCww/N48803q0+fPmZcblwzGjZsqF27dmnw4MGqWrWqfHx8FBISoubNm6tTp05m3H333actW7aoe/fuKlu2rLy8vFSmTBk98MAD+uGHH8yxu3NL586dtW3bNj366KOqWLGivL29Vbp0adWvX1+jRo0yh7mTsv96eXl5acWKFWrVqpUCAwMz7DM3v1+Qt2yG4cKtIgEAAAAALps4caImTZokKe2O8/369XNvQgWcYRgOx4hs3ry5OebmL7/8ogYNGri87Xbt2mnDhg2SpIMHDyoiIuKGcs2OwYMHa968eapQoYIOHjwob2/vHOUSERGhw4cPS1K2boT35ptvaujQofL19dVff/1l3jAOAOB+dE0CAAAAABQomzZtUs+ePfX111/r8OHD2rVrl4YOHWoWbGvWrKnIyEg3Z5l9zz33nHx9fXX8+HF9+umn+bJPwzD02muvSUrrOUrBFgCshRuRAQAAAAAKlNTUVH3yySf65JNPMiwLDAzUwoULC9TwSRUrVlRCQkK+7tNms2nv3r35uk8AQPYVnG8xAAAAAAAkVa1aVY888oiqVasmf39/+fr66qabbtLgwYO1a9cul8YzBQDAilwe03bjxo165ZVXtH37dp08eVLLli1T165dM10nKipKo0aN0u+//67w8HA9//zzjOEEAAAAAAAAAA643NM2Pj5ekZGRmjt3brbiDx48qLvvvlu33Xabdu7cqREjRuixxx7T119/7XKyAAAAAAAAAFDYudzT1m5lmy3LnrajR4/WypUr9dtvv5nzHn74YUVHR2vNmjU53TUAAAAAAAAAFEp5Pqbtli1b1L59e7t5HTp00JYtW5yuk5iYqNjYWHOKiYnR2bNndQP1ZQAAACDfGYah2NhY2rEAAABwSZ4XbU+dOqXQ0FC7eaGhoYqNjdWVK1ccrjN16lQFBwebU0hIiMqWLatLly7ldboAAAAoIqZOnaomTZooMDBQZcuWVdeuXTPcSb1du3ay2Wx206BBg7K9j0uXLik4OJh2LAAAAFyS50XbnBg7dqxiYmLM6ejRo+5OCQAAAIXMhg0bNHToUP34449au3atkpOTdddddyk+Pt4ubuDAgTp58qQ5TZ8+3U0ZAwAAoKjwyusdhIWF6fTp03bzTp8+raCgIBUrVszhOr6+vvL19c3r1AAAAFCEXX9/hYULF6ps2bLavn272rRpY8739/dXWFhYfqcHAACAIizPe9q2aNFC69ats5u3du1atWjRIq93DQAAAGRbTEyMJKlkyZJ28z/66COVLl1aderU0dixY3X58mWn27j+3gyxsbF5mjMAAAAKJ5d72sbFxWn//v3m84MHD2rnzp0qWbKkKlWqpLFjx+r48eP64IMPJEmDBg3SG2+8oWeeeUb/+te/9N133+nTTz/VypUrc+8oAAAAgBuQmpqqESNGqGXLlqpTp445v1evXqpcubLKly+v3bt3a/To0dq7d68+//xzh9uZOnWqJk2alF9pAwAAoJCyGS7eyjYqKkq33XZbhvl9+/bVwoUL1a9fPx06dEhRUVF264wcOVJ//PGHKlasqHHjxqlfv37Z3mdsbKyCg4MVExOjoKAgV9IFAAAAsjR48GCtXr1amzdvVsWKFZ3Gfffdd7rjjju0f/9+VatWLcPyxMRE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+/bttWDBAg0ZMkSNGzeWlDY8WK9evbRo0SJ5enpq9OjReuihh/Tnn3+a7eI1a9Zo7ty5evvtt7V69Wo9+OCD+v3331WtWrXsvpQAAAAuKxRFWymt6HhtTCwAAAAAN+bs2bOSpAoVKmQaN2DAAM2ePdss2s6fP18DBgzI9n7efvtt9evXT7fffrskqVWrVrrnnnv06aefaty4cfL29tb+/fvN4dBuvfXWbG978eLFuuOOO9SxY0dJ0sCBAzV79mxzeVBQkHr06GE+nzRpkl5//XWdOHHCPO4aNWro8ccflyR16dJFt912mz7++GM9//zz2c4DAADAVfwOHgAAAEAGpUuXliQdP34807hevXrp6NGj2rx5s3799Vft2rVLffr0kSTdcsstCggIUEBAgD766COH6x86dEjz5s1TSEiIOX3xxRc6ceKEJGnZsmX67bffVLNmTTVo0ECffvppto/hxIkTCg8Pt5tXqVIl8/GVK1c0ZMgQRUREKCgoSBEREZKkc+fOmTGVK1e2W79y5cpZviYAAAA3qtD0tAUAAACQe2rUqKGIiAh98skneu6555zGhYSEqFu3bpo/f76Cg4N1zz33KDQ0VJL0+++/Z7mf8PBwPfnkk5o2bZrD5Q0bNtTSpUuVmpqq5cuX66GHHlLbtm2zdR+G8uXL66effrKbd+TIETVr1kySNGPGDG3fvl2bN29WxYoVFR0drRIlSsgwDDP+8OHDGdZ3pbcvAABATtDTFgAAAEAGNptNc+bM0bRp0zRnzhydP39eUto4sQMGDLArZg4YMEBLlizRf//7X5eGRpCkxx9/XAsWLND69euVkpKixMREbdmyRXv27FFSUpI+/PBDXbx4UR4eHubYtV5eXipTpow8PDz0999/O932Qw89pG+//VbffPONrl69qvnz52vfvn3m8tjYWPn5+alEiRKKi4vTs88+m2Eb+/bt0zvvvKOrV69q5cqV+u677+yGVAAAAMgLFG0BAAAAOHTPPfdo9erVWrlypapVq6aQkBB1795dtWrVUrly5cy4du3aKTQ0VD4+PurUqZNL+2jQoIE5RmyZMmVUoUIFjRs3TomJiZLSbmx20003KTAwUE888YQWLVqkUqVKqVixYpowYYI6deqkkJAQLVq0KMO2a9asqffff1+DBw9WqVKltGXLFt1+++3y9fWVJI0aNUqenp4K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yRozZoyaNm2q+vXr66GHHtLFixclSYsWLVKzZs3UoEEDRUZG6ssvv7Tb/vDhw9WiRQvdddddOnbsmAIDAxUYGChJstlsatiwoSTp3nvv1dGjR7V7925z/fnz56tPnz7y9vbW3Llz1bp1aw0cONBcXqJECQ0aNChPXyPAFbRpiibOe+FGm6bo4ZwXDLRjrY+ibSEUExOjqKgodevWzd2pIJ9wzgu/P//8U+fPn1e7du0kSZcuXbL7idHRo0fdmyByHee8YNq5c6e+/PJL7dmzR6dPn9bSpUslSa+88oqKFy+urVu3aufOnapbt66ef/55SVKHDh30448/aseOHfriiy80cOBAJSYmmtvct2+fNm7cqO+++0533XWXAgMDValSJfXo0UNvvPGG2Wj29vbWo48+qvnz50uSEhIS9PHHH2vAgAGSpO3bt6tFixb5+XIALqNNUzRx3gs32jRFD+e8YKIdaz1e7k4AueeVV17R/PnztW/fPt1999267bbb3J0S8hjnvPDr0aOHPDw8tHfvXs2aNUtlypRRfHy8AgMDM4xJhMKBc16w3X///fL395ckNW3aVH///bckafny5YqJiTEbv0lJSYqIiJAkHTx4UL1799axY8fk5eWlCxcu6ODBg6pVq5Yk6ZFHHpG3t7ckyd/fX5s2bdLOnTu1adMmff7553r55Ze1a9culSxZUgMGDFDbtm01ffp0ff7557r55pt188035/OrALiONk3RxHkv3GjTFD2c84KNdqz10NO2EHn66ae1e/du7du3T9u2bWNw7yKAc174LV68WHv27NE333yjMWPG6Ndff3V3SshjnPOCzc/Pz3zs6empq1evSpIMw9CcOXO0c+dO7dy5U3/88YdWrVolSXr44Yf12GOP6bffftPOnTsVEBCghIQEczsBAQF2+7DZbGrQoIGGDx+udevWKSAgQFFRUZKk2rVr66abbtKXX36p+fPnm70TJKlRo0basmVLXh06cENo0xRNnPfCjTZN0cM5L9hox1oPRdtCqFKlSpozZ44mT56sK1euuDsd5APOeeHXvn17DR482PwZCgo/znnh0rVrV82aNcu8M/rly5f1+++/S5IuXryoKlWqSJL++9//mj8Tc+TPP/+0G+vr6NGjOnv2rKpWrWrOGzBggKZMmaKtW7eqR48e5vwhQ4Zow4YNWrBggTkvOjpa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",
640
+ "text/plain": [
641
+ "<Figure size 1400x600 with 2 Axes>"
642
+ ]
643
+ },
644
+ "metadata": {},
645
+ "output_type": "display_data"
646
+ }
647
+ ],
648
+ "source": [
649
+ "fig, axes = plt.subplots(1, 2, figsize=(14, 6))\n",
650
+ "\n",
651
+ "models_list = list(comp_df.index)\n",
652
+ "x = np.arange(len(models_list))\n",
653
+ "w = 0.35\n",
654
+ "c_tr = ['#534AB7','#7F77DD','#0F6E56','#5DCAA5','#993C1D']\n",
655
+ "c_te = ['#9B96E8','#B8B4F0','#5DCAA5','#9FE1CB','#E8593C']\n",
656
+ "\n",
657
+ "axes[0].bar(x-w/2, comp_df['f1_train'], w, label='Train', color=c_tr, alpha=0.85)\n",
658
+ "axes[0].bar(x+w/2, comp_df['f1_test'], w, label='Test', color=c_te, alpha=0.85)\n",
659
+ "axes[0].axhline(0.75, color='gray', linestyle='--', alpha=0.4)\n",
660
+ "axes[0].set_title('F1 Train vs Test', fontweight='bold')\n",
661
+ "axes[0].set_xticks(x)\n",
662
+ "axes[0].set_xticklabels([m.replace(' ','\\n') for m in models_list], fontsize=8)\n",
663
+ "axes[0].set_ylim(0.5, 1.0)\n",
664
+ "axes[0].legend()\n",
665
+ "\n",
666
+ "# GAP comparativo\n",
667
+ "cv_gaps = [r['cv_test_gap_pp'] if r['cv_test_gap_pp'] else 0\n",
668
+ " for _, r in comp_df.iterrows()]\n",
669
+ "tr_gaps = comp_df['train_test_gap_pp'].tolist()\n",
670
+ "x2 = np.arange(len(models_list))\n",
671
+ "axes[1].bar(x2-w/2, tr_gaps, w, label='Train-Test gap', color='#E8593C', alpha=0.7)\n",
672
+ "axes[1].bar(x2+w/2, cv_gaps, w, label='CV-Test gap', color='#5DCAA5', alpha=0.7)\n",
673
+ "axes[1].axhline(5, color='red', linestyle='--', lw=1.5, label='Límite 5pp')\n",
674
+ "axes[1].set_title('Comparativa gaps (pp) — verde = rubrica correcta', fontweight='bold')\n",
675
+ "axes[1].set_xticks(x2)\n",
676
+ "axes[1].set_xticklabels([m.replace(' ','\\n') for m in models_list], fontsize=8)\n",
677
+ "axes[1].legend(fontsize=9)\n",
678
+ "\n",
679
+ "plt.tight_layout()\n",
680
+ "plt.savefig(PROJECT_ROOT / 'reports' / 'v2' / '14_optuna_comparativa.png',\n",
681
+ " dpi=150, bbox_inches='tight')\n",
682
+ "plt.show()"
683
+ ]
684
+ },
685
+ {
686
+ "cell_type": "markdown",
687
+ "metadata": {},
688
+ "source": [
689
+ "## 8. Guardar ganador y best_params.yaml"
690
+ ]
691
+ },
692
+ {
693
+ "cell_type": "code",
694
+ "execution_count": 14,
695
+ "metadata": {},
696
+ "outputs": [
697
+ {
698
+ "name": "stdout",
699
+ "output_type": "stream",
700
+ "text": [
701
+ "Modelo guardado: /mnt/c/Users/under/Documents/F5/3_Projects/Project_9_Equipo3/Project_YT/models/final_model.joblib\n",
702
+ "best_params.yaml guardado\n",
703
+ "winner: LR tuned\n",
704
+ "hyperparameters:\n",
705
+ " ngram_range: '1_2'\n",
706
+ " max_features: 4045\n",
707
+ " min_df: 2\n",
708
+ " sublinear_tf: false\n",
709
+ " C: 0.3235215031170205\n",
710
+ "results:\n",
711
+ " f1_test: 0.7579\n",
712
+ " f1_train: 0.8987\n",
713
+ " train_test_gap_pp: 14.07\n",
714
+ " cv_test_gap_pp: 4.76\n",
715
+ " roc_auc: 0.81\n",
716
+ " fp: 18\n",
717
+ " fn: 30\n",
718
+ "\n"
719
+ ]
720
+ }
721
+ ],
722
+ "source": [
723
+ "pipeline_map = {\n",
724
+ " 'LR baseline': lr_baseline_pipe,\n",
725
+ " 'LR tuned' : lr_tuned_pipe,\n",
726
+ " 'RF baseline': rf_baseline_pipe,\n",
727
+ " 'RF tuned' : rf_tuned_pipe,\n",
728
+ " 'LinearSVC' : svc_pipeline,\n",
729
+ "}\n",
730
+ "best_pipeline = pipeline_map[best_name]\n",
731
+ "\n",
732
+ "MODELS_DIR = PROJECT_ROOT / 'models'\n",
733
+ "MODELS_DIR.mkdir(exist_ok=True)\n",
734
+ "model_path = MODELS_DIR / 'final_model.joblib'\n",
735
+ "joblib.dump(best_pipeline, model_path)\n",
736
+ "print(f'Modelo guardado: {model_path}')\n",
737
+ "\n",
738
+ "best_row = comp_df.loc[best_name]\n",
739
+ "best_trial_params = {}\n",
740
+ "if 'LR tuned' == best_name:\n",
741
+ " best_trial_params = study_lr.best_trial.params\n",
742
+ "elif 'RF tuned' == best_name:\n",
743
+ " best_trial_params = study_rf.best_trial.params\n",
744
+ "\n",
745
+ "best_out = {\n",
746
+ " 'winner' : best_name,\n",
747
+ " 'hyperparameters' : best_trial_params,\n",
748
+ " 'results': {\n",
749
+ " 'f1_test' : float(best_row['f1_test']),\n",
750
+ " 'f1_train' : float(best_row['f1_train']),\n",
751
+ " 'train_test_gap_pp': float(best_row['train_test_gap_pp']),\n",
752
+ " 'cv_test_gap_pp' : float(best_row['cv_test_gap_pp'])\n",
753
+ " if best_row['cv_test_gap_pp'] is not None else None,\n",
754
+ " 'roc_auc' : float(best_row['roc_auc']),\n",
755
+ " 'fp' : int(best_row['fp']),\n",
756
+ " 'fn' : int(best_row['fn']),\n",
757
+ " }\n",
758
+ "}\n",
759
+ "\n",
760
+ "import yaml\n",
761
+ "best_path = PROJECT_ROOT / 'configs' / 'best_params.yaml'\n",
762
+ "with open(best_path, 'w') as f:\n",
763
+ " yaml.dump(best_out, f, default_flow_style=False, sort_keys=False)\n",
764
+ "print(f'best_params.yaml guardado')\n",
765
+ "with open(best_path) as f: print(f.read())"
766
+ ]
767
+ },
768
+ {
769
+ "cell_type": "code",
770
+ "execution_count": 15,
771
+ "metadata": {},
772
+ "outputs": [
773
+ {
774
+ "name": "stdout",
775
+ "output_type": "stream",
776
+ "text": [
777
+ "Verificacion final_model.joblib:\n",
778
+ " ✅ [TOXICO 0.70] you are a stupid thug get out\n",
779
+ " ✅ [NO TOXICO 0.45] I think the police should be more transparent\n",
780
+ " ✅ [TOXICO 0.56] black people are criminal thugs\n",
781
+ " ✅ [NO TOXICO 0.31] thank you for sharing this video\n"
782
+ ]
783
+ }
784
+ ],
785
+ "source": [
786
+ "# Verificacion\n",
787
+ "loaded = joblib.load(model_path)\n",
788
+ "tests = [\n",
789
+ " ('you are a stupid thug get out', True),\n",
790
+ " ('I think the police should be more transparent', False),\n",
791
+ " ('black people are criminal thugs', True),\n",
792
+ " ('thank you for sharing this video', False),\n",
793
+ "]\n",
794
+ "print('Verificacion final_model.joblib:')\n",
795
+ "for text, expected in tests:\n",
796
+ " pred = loaded.predict([text])[0]\n",
797
+ " prob = loaded.predict_proba([text])[0][1]\n",
798
+ " ok = '✅' if pred == expected else '❌'\n",
799
+ " print(f' {ok} [{\"TOXICO\" if pred else \"NO TOXICO\"} {prob:.2f}] {text[:55]}')"
800
+ ]
801
+ },
802
+ {
803
+ "cell_type": "markdown",
804
+ "metadata": {},
805
+ "source": [
806
+ "## 9. Registro en MLflow"
807
+ ]
808
+ },
809
+ {
810
+ "cell_type": "code",
811
+ "execution_count": 16,
812
+ "metadata": {},
813
+ "outputs": [
814
+ {
815
+ "name": "stderr",
816
+ "output_type": "stream",
817
+ "text": [
818
+ "2026/05/18 10:12:46 WARNING mlflow.models.model: `artifact_path` is deprecated. Please use `name` instead.\n",
819
+ "2026/05/18 10:12:48 WARNING mlflow.sklearn: Saving scikit-learn models in the pickle or cloudpickle format requires exercising caution because these formats rely on Python's object serialization mechanism, which can execute arbitrary code during deserialization. The recommended safe alternative is the 'skops' format. For more information, see: https://scikit-learn.org/stable/model_persistence.html\n"
820
+ ]
821
+ },
822
+ {
823
+ "name": "stdout",
824
+ "output_type": "stream",
825
+ "text": [
826
+ " ✅ lr_tuned_optuna\n",
827
+ " ✅ rf_tuned_optuna\n",
828
+ " ✅ linear_svc\n"
829
+ ]
830
+ }
831
+ ],
832
+ "source": [
833
+ "MLFLOW_DIR = PROJECT_ROOT / 'mlruns'\n",
834
+ "mlflow.set_tracking_uri(f'file://{MLFLOW_DIR}')\n",
835
+ "mlflow.set_experiment('Youtube_project_experiment')\n",
836
+ "\n",
837
+ "runs_info = [\n",
838
+ " ('lr_tuned_optuna', study_lr, metrics_lr_tuned, lr_tuned_pipe,\n",
839
+ " {'model':'LR','optuna_trials':N_TRIALS}),\n",
840
+ " ('rf_tuned_optuna', study_rf, metrics_rf_tuned, rf_tuned_pipe,\n",
841
+ " {'model':'RF','optuna_trials':N_TRIALS}),\n",
842
+ " ('linear_svc', None, metrics_svc, svc_pipeline,\n",
843
+ " {'model':'LinearSVC','C':1.0}),\n",
844
+ "]\n",
845
+ "\n",
846
+ "for run_name, study, mets, pipe, extra in runs_info:\n",
847
+ " with mlflow.start_run(run_name=run_name):\n",
848
+ " for k,v in extra.items(): mlflow.log_param(k, v)\n",
849
+ " if study:\n",
850
+ " mlflow.log_param('best_trial', study.best_trial.number)\n",
851
+ " for k,v in study.best_trial.params.items(): mlflow.log_param(k, v)\n",
852
+ " mlflow.log_metric('test_f1', mets['f1_test'])\n",
853
+ " mlflow.log_metric('train_f1', mets['f1_train'])\n",
854
+ " mlflow.log_metric('train_test_gap_pp', mets['train_test_gap_pp'])\n",
855
+ " if mets['cv_test_gap_pp'] is not None:\n",
856
+ " mlflow.log_metric('cv_mean', mets['cv_mean'])\n",
857
+ " mlflow.log_metric('cv_test_gap_pp', mets['cv_test_gap_pp'])\n",
858
+ " mlflow.log_metric('roc_auc', mets['roc_auc'])\n",
859
+ " if run_name.split('_')[0].upper() in best_name.upper() and 'tuned' in best_name.lower():\n",
860
+ " mlflow.sklearn.log_model(pipe, 'final_model')\n",
861
+ " print(f' ✅ {run_name}')\n",
862
+ "mlflow.log_artifact(str(PROJECT_ROOT / 'reports' / 'v2' / '14_optuna_comparativa.png'))"
863
+ ]
864
+ },
865
+ {
866
+ "cell_type": "markdown",
867
+ "metadata": {},
868
+ "source": [
869
+ "## 10. Conclusiones"
870
+ ]
871
+ },
872
+ {
873
+ "cell_type": "code",
874
+ "execution_count": 17,
875
+ "metadata": {},
876
+ "outputs": [
877
+ {
878
+ "name": "stdout",
879
+ "output_type": "stream",
880
+ "text": [
881
+ "\n",
882
+ "CONCLUSIONES — OPTIMIZACION + LinearSVC\n",
883
+ "=======================================================\n",
884
+ "5 modelos evaluados bajo las mismas condiciones.\n",
885
+ "\n",
886
+ "Mejora Optuna:\n",
887
+ " LR: +0.48pp F1 test\n",
888
+ " RF: -6.07pp F1 test\n",
889
+ "\n",
890
+ "Ganador: LR tuned\n",
891
+ " F1 test : 0.7579\n",
892
+ " train-test gap : 14.07pp\n",
893
+ " cv-test gap : 4.76pp\n",
894
+ "\n",
895
+ "Nota metodologica:\n",
896
+ " El train-test gap esta inflado por ser in-sample vs OOS.\n",
897
+ " El cv-test gap compara OOS vs OOS — es la metrica correcta\n",
898
+ " para la rubrica. Ver informe metodologico adjunto.\n",
899
+ "\n",
900
+ "Siguiente: 07_data_augmentation.ipynb\n",
901
+ " Explorar si augmentation mejora los FN sin aumentar FP.\n",
902
+ "\n"
903
+ ]
904
+ }
905
+ ],
906
+ "source": [
907
+ "lr_delta = metrics_lr_tuned['f1_test'] - metrics_lr_base['f1_test']\n",
908
+ "rf_delta = metrics_rf_tuned['f1_test'] - metrics_rf_base['f1_test']\n",
909
+ "\n",
910
+ "print(f\"\"\"\n",
911
+ "CONCLUSIONES — OPTIMIZACION + LinearSVC\n",
912
+ "{'='*55}\n",
913
+ "5 modelos evaluados bajo las mismas condiciones.\n",
914
+ "\n",
915
+ "Mejora Optuna:\n",
916
+ " LR: {lr_delta*100:+.2f}pp F1 test\n",
917
+ " RF: {rf_delta*100:+.2f}pp F1 test\n",
918
+ "\n",
919
+ "Ganador: {best_name}\n",
920
+ " F1 test : {comp_df.loc[best_name, 'f1_test']:.4f}\n",
921
+ " train-test gap : {comp_df.loc[best_name, 'train_test_gap_pp']:.2f}pp\n",
922
+ " cv-test gap : {comp_df.loc[best_name, 'cv_test_gap_pp']:.2f}pp\n",
923
+ "\n",
924
+ "Nota metodologica:\n",
925
+ " El train-test gap esta inflado por ser in-sample vs OOS.\n",
926
+ " El cv-test gap compara OOS vs OOS — es la metrica correcta\n",
927
+ " para la rubrica. Ver informe metodologico adjunto.\n",
928
+ "\n",
929
+ "Siguiente: 07_data_augmentation.ipynb\n",
930
+ " Explorar si augmentation mejora los FN sin aumentar FP.\n",
931
+ "\"\"\")"
932
+ ]
933
+ }
934
+ ],
935
+ "metadata": {
936
+ "kernelspec": {
937
+ "display_name": "py310",
938
+ "language": "python",
939
+ "name": "python3"
940
+ },
941
+ "language_info": {
942
+ "codemirror_mode": {
943
+ "name": "ipython",
944
+ "version": 3
945
+ },
946
+ "file_extension": ".py",
947
+ "mimetype": "text/x-python",
948
+ "name": "python",
949
+ "nbconvert_exporter": "python",
950
+ "pygments_lexer": "ipython3",
951
+ "version": "3.10.20"
952
+ }
953
+ },
954
+ "nbformat": 4,
955
+ "nbformat_minor": 4
956
+ }
notebooks/07_augmentation_clean_v2.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
notebooks/08_transformers_clean_v2.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
reports/v2/01_distribucion_target.png ADDED

Git LFS Details

  • SHA256: 94aee2f843dcb2fea0617bf2b566b64c984335be6137bfb2e55016cb6c0456f1
  • Pointer size: 131 Bytes
  • Size of remote file: 111 kB
reports/v2/02_longitud_texto.png ADDED

Git LFS Details

  • SHA256: 51e552550bf6c06272d56283a198eadd64e11b41b819c0b2559fa026c3a7b286
  • Pointer size: 131 Bytes
  • Size of remote file: 148 kB
reports/v2/03_wordclouds.png ADDED

Git LFS Details

  • SHA256: 337d30ba1613678f4f88df6dbd5f770ce6defa5ef86dd791fc44e9bfbb84333a
  • Pointer size: 131 Bytes
  • Size of remote file: 659 kB
reports/v2/04_correlacion_sublabels.png ADDED

Git LFS Details

  • SHA256: ec4fb576dc6beb949366d9ebd4e2287ad60f1cfe91cbc563fae4d032f7871a0a
  • Pointer size: 130 Bytes
  • Size of remote file: 93.3 kB
reports/v2/05_multilabel_overlap.png ADDED

Git LFS Details

  • SHA256: 5134524ba4107c444f60220d6456f041b5639b4fb64cf9ef1ac46f4f58525cf2
  • Pointer size: 130 Bytes
  • Size of remote file: 42.3 kB
reports/v2/06_toxicidad_por_video.png ADDED

Git LFS Details

  • SHA256: 9558468fbe023e196266028097908bce737989bd9e045240a4cedf61b39e8374
  • Pointer size: 130 Bytes
  • Size of remote file: 84 kB
reports/v2/07_tokens_comparativa.png ADDED

Git LFS Details

  • SHA256: 398bf94aac1d980c76d7ea2a3234718e87784e1b13a41915d891c2b7d6316e86
  • Pointer size: 130 Bytes
  • Size of remote file: 65.3 kB
reports/v2/08_tfidf_top_features.png ADDED

Git LFS Details

  • SHA256: 7c93688b0bee2bb59e729d835fed338855c9b624dab3be4459381af3bc17c83d
  • Pointer size: 130 Bytes
  • Size of remote file: 89.3 kB
reports/v2/09_cv_metrics.png ADDED

Git LFS Details

  • SHA256: 2c8fb2ede9b78f1ae4c71fa2a3b006f4ed1b9cf6a4bc77b1c4c1085602dfdc13
  • Pointer size: 130 Bytes
  • Size of remote file: 58.6 kB
reports/v2/10_baseline_confusion_roc.png ADDED

Git LFS Details

  • SHA256: 0c79b6762a53b9eef715954c620960c4cf104e0ae13fbceab5b8865fe412dcd0
  • Pointer size: 130 Bytes
  • Size of remote file: 79.2 kB
reports/v2/11_lr_coeficientes.png ADDED

Git LFS Details

  • SHA256: 1884cc81fa44a6e054f549cb5bfe0d85e6db74a9bab7ab81832f0e5e206cde6f
  • Pointer size: 130 Bytes
  • Size of remote file: 98.3 kB
reports/v2/12_ensemble_comparativa.png ADDED

Git LFS Details

  • SHA256: b581c9efcbe691e10829ad8ae9f4ccf2ff9f8b0af48c1e4dbbf53e3a7bb579e3
  • Pointer size: 130 Bytes
  • Size of remote file: 63.1 kB
reports/v2/13_best_model_test.png ADDED

Git LFS Details

  • SHA256: 5d846f28a613e40740000f03f791ba1d68ed3d1271139814199b16b5f8761f4f
  • Pointer size: 130 Bytes
  • Size of remote file: 80.6 kB
reports/v2/14_optuna_comparativa.png ADDED

Git LFS Details

  • SHA256: ecf8f4009e616c4dfedd46e5057a5ba723bb3ff9a0eaaed2347763c94aaddd72
  • Pointer size: 130 Bytes
  • Size of remote file: 51.8 kB
reports/v2/15_augmentation_comparativa.png ADDED

Git LFS Details

  • SHA256: 58fc17e62b6c09ec7d95c5ac2f11d50430e6fc3acd66bd52b7b74222609c514a
  • Pointer size: 130 Bytes
  • Size of remote file: 56.9 kB
reports/v2/16_augmentation_confusion.png ADDED

Git LFS Details

  • SHA256: 2f3aeeae86185e63c6e822d70b1594835428cdb25da39275f059b792e6a989e8
  • Pointer size: 130 Bytes
  • Size of remote file: 67.9 kB
reports/v2/nb08_comparativa_final.png ADDED

Git LFS Details

  • SHA256: ed57944284a235727aff26959b851ae33907d0634a3a9f8eed42985c2f796f48
  • Pointer size: 130 Bytes
  • Size of remote file: 84.8 kB
reports/v2/nb08_distilbert_base_cm.png ADDED

Git LFS Details

  • SHA256: 1be585ccfc189fd68e1fbaad8208d4d439ec3c013268ef14eaf0667c5db21669
  • Pointer size: 130 Bytes
  • Size of remote file: 28.9 kB
reports/v2/nb08_distilbert_toxic_(fine-tuned)_cm.png ADDED

Git LFS Details

  • SHA256: 73cb363320a0a3057039d01326112c6661b96bba0fafbe4fc3b80ee57020aa83
  • Pointer size: 130 Bytes
  • Size of remote file: 31.4 kB
reports/v2/nb08_roberta_hate_cm.png ADDED

Git LFS Details

  • SHA256: 301dae6fc83c9e3cde8d90dab84448dfbb2c36aae08ab141087f46c0035a9284
  • Pointer size: 130 Bytes
  • Size of remote file: 31.2 kB
src/app/app.py ADDED
@@ -0,0 +1,764 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ src/app/streamlit_app.py
3
+
4
+ App SignalMod — detección de hate speech estilo YouTube.
5
+ Ejecutar: streamlit run src/app/streamlit_app.py
6
+ """
7
+
8
+ import html
9
+ import sys
10
+ import random
11
+ import datetime
12
+ from pathlib import Path
13
+
14
+ import streamlit as st
15
+ import pandas as pd
16
+
17
+ from transformers.utils import logging
18
+ logging.set_verbosity_error()
19
+
20
+ # ── Paths ─────────────────────────────────────────────────────────────────────
21
+ PROJECT_ROOT = Path(__file__).resolve().parents[2]
22
+ sys.path.insert(0, str(PROJECT_ROOT))
23
+
24
+ try:
25
+ from src.service.model_service import ModelService, AVAILABLE_MODELS
26
+ except ImportError:
27
+ sys.path.insert(0, str(Path(__file__).parent.parent))
28
+ from service.model_service import ModelService, AVAILABLE_MODELS
29
+
30
+ # ── Config ────────────────────────────────────────────────────────────────────
31
+ st.set_page_config(
32
+ page_title="SignalMod",
33
+ page_icon="🎬",
34
+ layout="wide",
35
+ initial_sidebar_state="expanded",
36
+ )
37
+
38
+ # ── CSS ───────────────────────────────────────────────────────────────────────
39
+ # Nota: NO ocultamos el header completo para preservar el botón de toggle del sidebar.
40
+ # Solo ocultamos el menú hamburguesa y el footer de Streamlit.
41
+ st.markdown("""
42
+ <style>
43
+ @import url('https://fonts.googleapis.com/css2?family=YouTube+Sans:wght@400;600;700&display=swap');
44
+
45
+ /* ── Ocultar solo elementos de branding, NO el header completo ── */
46
+ #MainMenu { visibility: hidden; }
47
+ footer { visibility: hidden; }
48
+
49
+ /* ── Fondo de la app: blanco limpio ── */
50
+ .stApp { background: #ffffff; }
51
+
52
+ /* ── Sidebar oscuro (como YouTube) ── */
53
+ section[data-testid="stSidebar"] {
54
+ background-color: #0f0f0f !important;
55
+ }
56
+ section[data-testid="stSidebar"] > div {
57
+ background-color: #0f0f0f !important;
58
+ }
59
+ /* Texto del sidebar en blanco */
60
+ section[data-testid="stSidebar"] p,
61
+ section[data-testid="stSidebar"] span,
62
+ section[data-testid="stSidebar"] label,
63
+ section[data-testid="stSidebar"] div {
64
+ color: #ffffff !important;
65
+ }
66
+ /* Botones del sidebar */
67
+ section[data-testid="stSidebar"] .stButton button {
68
+ background: transparent !important;
69
+ color: #e0e0e0 !important;
70
+ border: none !important;
71
+ text-align: left !important;
72
+ justify-content: flex-start !important;
73
+ border-radius: 10px !important;
74
+ padding: 0.5rem 0.75rem !important;
75
+ font-size: 0.9rem !important;
76
+ font-weight: 400 !important;
77
+ width: 100% !important;
78
+ }
79
+ section[data-testid="stSidebar"] .stButton button:hover {
80
+ background: rgba(255,255,255,0.1) !important;
81
+ color: #ffffff !important;
82
+ }
83
+ /* Botón activo en el sidebar */
84
+ section[data-testid="stSidebar"] .stButton button[data-active="true"] {
85
+ background: rgba(255,255,255,0.15) !important;
86
+ color: #ffffff !important;
87
+ font-weight: 600 !important;
88
+ }
89
+ /* Divider del sidebar */
90
+ section[data-testid="stSidebar"] hr {
91
+ border-color: rgba(255,255,255,0.15) !important;
92
+ }
93
+ /* Badge de modelo activo en sidebar */
94
+ .sidebar-model-info {
95
+ background: rgba(255,255,255,0.08);
96
+ border-radius: 8px;
97
+ padding: 8px 12px;
98
+ margin: 8px 0;
99
+ font-size: 0.75rem;
100
+ color: #aaaaaa;
101
+ }
102
+ .sidebar-model-info strong { color: #ffffff; }
103
+
104
+ /* ── Área principal: fondo blanco, texto oscuro ── */
105
+ .main-area { background: #ffffff; }
106
+
107
+ /* ── Video thumbnail ── */
108
+ .video-thumb {
109
+ background: linear-gradient(135deg, #0d0d1a 0%, #1a0a2e 50%, #0d1a1a 100%);
110
+ border-radius: 12px;
111
+ height: 340px;
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ }
116
+ .play-btn {
117
+ width: 72px; height: 72px;
118
+ background: rgba(255,255,255,0.9);
119
+ border-radius: 50%;
120
+ display: flex; align-items: center; justify-content: center;
121
+ font-size: 2rem; cursor: pointer;
122
+ box-shadow: 0 4px 20px rgba(0,0,0,0.4);
123
+ }
124
+
125
+ /* ── Títulos de video ── */
126
+ .video-title {
127
+ font-size: 1.15rem; font-weight: 700;
128
+ color: #0f0f0f; margin: 0.75rem 0 0.3rem;
129
+ line-height: 1.4;
130
+ }
131
+ .video-meta { font-size: 0.82rem; color: #606060; }
132
+ .channel-name { font-weight: 600; font-size: 0.9rem; color: #0f0f0f; }
133
+
134
+ /* ── Badges ── */
135
+ .badge {
136
+ display: inline-block;
137
+ padding: 2px 9px; border-radius: 12px;
138
+ font-size: 0.72rem; font-weight: 700;
139
+ margin-left: 6px; vertical-align: middle;
140
+ }
141
+ .badge-toxic { background: #cc0000; color: #ffffff; }
142
+ .badge-safe { background: #00c853; color: #ffffff; }
143
+
144
+ /* ── Comentarios ── */
145
+ .comment-wrap {
146
+ display: flex; gap: 12px;
147
+ padding: 12px 0; border-bottom: 1px solid #f0f0f0;
148
+ }
149
+ .c-avatar {
150
+ width: 36px; height: 36px; min-width: 36px;
151
+ border-radius: 50%; background: #cc0000;
152
+ display: flex; align-items: center; justify-content: center;
153
+ color: #ffffff; font-weight: 700; font-size: 0.85rem;
154
+ flex-shrink: 0;
155
+ }
156
+ .c-avatar.safe { background: #606060; }
157
+ .c-body { flex: 1; min-width: 0; }
158
+ .c-header { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
159
+ .c-user { font-size: 0.84rem; font-weight: 600; color: #0f0f0f; }
160
+ .c-time { font-size: 0.75rem; color: #909090; margin-left: 4px; }
161
+ .c-text { font-size: 0.88rem; color: #2d2d2d; margin-top: 4px; line-height: 1.55; }
162
+ .c-text.toxic {
163
+ background: #fff5f5;
164
+ border-left: 3px solid #cc0000;
165
+ padding: 6px 10px; border-radius: 0 6px 6px 0;
166
+ margin-top: 6px;
167
+ }
168
+ .c-flagged { font-size: 0.77rem; color: #cc0000; font-weight: 500; margin-top: 4px; }
169
+
170
+ /* ── Toxicity bar inline ── */
171
+ .tox-row {
172
+ display: flex; align-items: center; gap: 8px;
173
+ font-size: 0.8rem; color: #606060; margin-top: 6px; flex-wrap: wrap;
174
+ }
175
+ .tox-bar-bg {
176
+ flex: 1; max-width: 120px;
177
+ background: #e5e5e5; border-radius: 4px; height: 6px;
178
+ }
179
+ .tox-bar-fill { height: 6px; border-radius: 4px; }
180
+
181
+ /* ── Sugeridos ── */
182
+ .sug-card {
183
+ display: flex; gap: 8px; margin-bottom: 10px;
184
+ cursor: pointer;
185
+ }
186
+ .sug-thumb {
187
+ width: 120px; min-width: 120px; height: 68px;
188
+ background: #1a1a2e; border-radius: 6px;
189
+ display: flex; align-items: center; justify-content: center;
190
+ font-size: 1.4rem; flex-shrink: 0;
191
+ }
192
+ .sug-title { font-size: 0.82rem; font-weight: 600; color: #0f0f0f; line-height: 1.3; }
193
+ .sug-ch { font-size: 0.75rem; color: #606060; margin-top: 2px; }
194
+ .sug-meta { font-size: 0.72rem; color: #909090; }
195
+
196
+ /* ── Section header ── */
197
+ .sec-title {
198
+ font-size: 1rem; font-weight: 700; color: #0f0f0f;
199
+ margin: 1.25rem 0 0.75rem; padding-bottom: 0.5rem;
200
+ border-bottom: 1px solid #e5e5e5;
201
+ }
202
+
203
+ /* ── Modal body fixes ── */
204
+ [data-testid="stDialog"] { background: #ffffff; }
205
+
206
+ /* ── Hub cards ── */
207
+ .hub-card {
208
+ background: #ffffff; border: 1px solid #e5e5e5;
209
+ border-radius: 12px; padding: 1rem;
210
+ }
211
+ .hub-kpi-label { font-size: 0.72rem; color: #606060; text-transform: uppercase;
212
+ letter-spacing: 0.5px; margin-bottom: 4px; }
213
+ .hub-kpi-val { font-size: 1.8rem; font-weight: 700; color: #0f0f0f; }
214
+
215
+ /* ── Model cards (settings) ── */
216
+ .model-card {
217
+ background: #ffffff; border: 1.5px solid #e5e5e5;
218
+ border-radius: 10px; padding: 14px 16px; margin-bottom: 8px;
219
+ }
220
+ .model-card.active {
221
+ border-color: #cc0000; background: #fff5f5;
222
+ }
223
+ .model-card-name { font-size: 0.95rem; font-weight: 600; color: #0f0f0f; }
224
+ .model-card-desc { font-size: 0.8rem; color: #606060; margin-top: 3px; }
225
+ .model-pill {
226
+ display: inline-block; background: #f0f0f0; color: #333;
227
+ border-radius: 6px; padding: 2px 8px; font-size: 0.73rem; margin-right: 4px;
228
+ }
229
+ </style>
230
+ """, unsafe_allow_html=True)
231
+
232
+
233
+ # ── Session state init ────────────────────────────────────────────────────────
234
+ def _init_state():
235
+ defaults = {
236
+ "page" : "Home",
237
+ "selected_model": list(AVAILABLE_MODELS.keys())[0],
238
+ "threshold" : 0.5,
239
+ "pending_modal" : None, # dict con el comentario pendiente de decisión
240
+ "comments": [
241
+ {"user": "user_prime", "initial": "U",
242
+ "text": "Excelente video, muy informativo!", "time": "1 h",
243
+ "is_toxic": False, "probability": 0.04, "labels": []},
244
+ {"user": "troll_master", "initial": "T",
245
+ "text": "Esto es una basura completa", "time": "30 min",
246
+ "is_toxic": True, "probability": 0.91, "labels": ["Insulto","Agresividad"]},
247
+ {"user": "curious_viewer", "initial": "C",
248
+ "text": "¿Alguien puede explicar esto mejor?", "time": "15 min",
249
+ "is_toxic": False, "probability": 0.07, "labels": []},
250
+ ],
251
+ "hub_history": [
252
+ {"Usuario": "@user_992", "Comentario": '"No puedo creer que seas tan..."', "Score": 0.94, "Acción": "🚫 Bloqueado"},
253
+ {"Usuario": "@alpha_mod", "Comentario": '"Spam repetitivo de enlaces."', "Score": 0.82, "Acción": "🚩 Revisión"},
254
+ {"Usuario": "@anon_404", "Comentario": '"Discurso de odio en contexto."', "Score": 0.98, "Acción": "📋 Archivado"},
255
+ {"Usuario": "@user_123", "Comentario": '"¡Gran contenido, sigan!"', "Score": 0.03, "Acción": "✅ Aprobado"},
256
+ {"Usuario": "@viewer_x", "Comentario": '"Esta gente debería desaparecer."',"Score": 0.97, "Acción": "🚫 Bloqueado"},
257
+ ],
258
+ }
259
+ for k, v in defaults.items():
260
+ if k not in st.session_state:
261
+ st.session_state[k] = v
262
+
263
+ _init_state()
264
+
265
+
266
+ # ── Model cache ───────────────────────────────────────────────────────────────
267
+ @st.cache_resource(show_spinner="Cargando modelo...")
268
+ def get_service(model_name: str) -> ModelService:
269
+ return ModelService(model_name, PROJECT_ROOT)
270
+
271
+
272
+ # ══════════════════════════════════════════════════════════════════════════════
273
+ # SIDEBAR
274
+ # ══════════════════════════════════════════════════════════════════════════════
275
+ def render_sidebar():
276
+ with st.sidebar:
277
+ # Logo
278
+ st.markdown(
279
+ "<div style='padding:0.5rem 0 0.25rem; font-size:1.3rem; font-weight:700;'>"
280
+ "🎬 <span style='color:#cc0000'>Signal</span>Mod</div>"
281
+ "<div style='font-size:0.65rem; color:#aaa; margin-bottom:1.2rem;'>"
282
+ "Signal within the Noise</div>",
283
+ unsafe_allow_html=True,
284
+ )
285
+
286
+ nav = {"Home": "🏠", "Moderator Hub": "📊", "Settings": "⚙️"}
287
+ for page, icon in nav.items():
288
+ label = f"{icon} {page}"
289
+ clicked = st.button(label, key=f"nav_{page}", use_container_width=True)
290
+ if clicked:
291
+ st.session_state.page = page
292
+ st.rerun()
293
+
294
+ st.divider()
295
+
296
+ # Info modelo activo
297
+ model_short = st.session_state.selected_model.split("(")[0].strip()
298
+ tox_cnt = sum(1 for c in st.session_state.comments if c["is_toxic"])
299
+ total_c = len(st.session_state.comments)
300
+
301
+ st.markdown(
302
+ f"<div class='sidebar-model-info'>"
303
+ f"Modelo activo<br><strong>{html.escape(model_short)}</strong>"
304
+ f"<br><br>Comentarios: <strong>{total_c}</strong>"
305
+ f" · Tóxicos: <strong style='color:#cc0000'>{tox_cnt}</strong>"
306
+ f"</div>",
307
+ unsafe_allow_html=True,
308
+ )
309
+
310
+
311
+ # ══════════════════════════════════════════════════════════════════════════════
312
+ # MODAL — toxicidad detectada
313
+ # ══════════════════════════════════════════════════════════════════════════════
314
+ @st.dialog("⚠️ Aviso de Toxicidad Detectada")
315
+ def show_toxicity_modal():
316
+ """
317
+ @st.dialog crea una ventana modal nativa de Streamlit (1.32+).
318
+ Cuando se llama a la función decorada, Streamlit renderiza el contenido
319
+ dentro de un overlay modal y pausa la ejecución normal del script.
320
+ """
321
+ data = st.session_state.pending_modal
322
+ if not data:
323
+ st.rerun()
324
+ return
325
+
326
+ text = data["text"]
327
+ prob = data["probability"]
328
+ lbls = data["labels"]
329
+ pct = int(prob * 100)
330
+ color = "#cc0000" if pct >= 70 else "#ff6d00" if pct >= 40 else "#f5a623"
331
+
332
+ st.markdown(
333
+ "<div style='text-align:center; font-size:3rem; color:#cc0000'>⚠️</div>",
334
+ unsafe_allow_html=True,
335
+ )
336
+ st.markdown(
337
+ f"<div style='background:#f8f8f8; border-radius:8px; padding:12px 16px;"
338
+ f"font-style:italic; color:#333; text-align:center; margin:8px 0;'>"
339
+ f"&quot;{html.escape(text[:140])}{'...' if len(text)>140 else ''}&quot;</div>",
340
+ unsafe_allow_html=True,
341
+ )
342
+
343
+ # Barra de toxicidad
344
+ st.markdown(
345
+ f"<div style='display:flex; justify-content:space-between; "
346
+ f"font-size:0.82rem; color:#606060; margin-top:12px;'>"
347
+ f"<span>ÍNDICE DE TOXICIDAD</span>"
348
+ f"<span style='color:{color}; font-weight:700'>{pct}%</span></div>"
349
+ f"<div style='background:#e5e5e5; border-radius:4px; height:8px; margin-top:4px;'>"
350
+ f"<div style='width:{pct}%; background:{color}; height:8px; border-radius:4px;'></div>"
351
+ f"</div>",
352
+ unsafe_allow_html=True,
353
+ )
354
+
355
+ # Etiquetas
356
+ if lbls:
357
+ tags = " ".join(
358
+ f"<span style='background:#ffe5e5; color:#cc0000; border-radius:14px;"
359
+ f"padding:3px 10px; font-size:0.76rem; font-weight:600; margin:3px;'>"
360
+ f"🚩 {html.escape(l)}</span>"
361
+ for l in lbls
362
+ )
363
+ st.markdown(f"<div style='margin-top:10px'>{tags}</div>", unsafe_allow_html=True)
364
+
365
+ st.markdown("<br>", unsafe_allow_html=True)
366
+
367
+ col1, col2 = st.columns(2)
368
+ with col1:
369
+ if st.button("✏️ Editar comentario", use_container_width=True, type="primary"):
370
+ st.session_state.pending_modal = None
371
+ st.rerun()
372
+ with col2:
373
+ if st.button("Publicar de todas maneras", use_container_width=True):
374
+ # Publicar aunque sea tóxico
375
+ c = st.session_state.pending_modal
376
+ st.session_state.comments.append(c)
377
+ st.session_state.hub_history.insert(0, {
378
+ "Usuario" : "@usuario",
379
+ "Comentario": f'"{c["text"][:45]}..."',
380
+ "Score" : round(c["probability"], 2),
381
+ "Acción" : "⚠️ Override usuario",
382
+ })
383
+ st.session_state.pending_modal = None
384
+ st.rerun()
385
+
386
+
387
+ # ══════════════════════════════════════════════════════════════════════════════
388
+ # HOME — interfaz estilo YouTube
389
+ # ══════════════════════════════════════════════════════════════════════════════
390
+ def render_home():
391
+ # Disparar modal si hay comentario pendiente
392
+ if st.session_state.pending_modal:
393
+ show_toxicity_modal()
394
+
395
+ col_main, col_right = st.columns([2.8, 1], gap="large")
396
+
397
+ with col_main:
398
+ # Video
399
+ st.markdown(
400
+ "<div class='video-thumb'><div class='play-btn'>▶</div></div>",
401
+ unsafe_allow_html=True,
402
+ )
403
+ st.markdown(
404
+ "<div class='video-title'>AI Moderation Demo — Detección de Hate Speech en tiempo real</div>"
405
+ "<div class='video-meta'>15k vistas · 2 horas atrás</div>",
406
+ unsafe_allow_html=True,
407
+ )
408
+ row_ch, row_sub = st.columns([3, 1])
409
+ with row_ch:
410
+ st.markdown(
411
+ "<div style='display:flex; align-items:center; gap:10px; margin:10px 0;'>"
412
+ "<div style='width:36px; height:36px; border-radius:50%; background:#cc0000;"
413
+ "display:flex; align-items:center; justify-content:center; color:#fff;"
414
+ "font-weight:700;'>S</div>"
415
+ "<div><div class='channel-name'>SignalMod AI</div>"
416
+ "<div class='video-meta'>1.2M suscriptores</div></div></div>",
417
+ unsafe_allow_html=True,
418
+ )
419
+
420
+ st.divider()
421
+
422
+ # ── Comentarios ────────────────────────────────────────────────────
423
+ tox_cnt = sum(1 for c in st.session_state.comments if c["is_toxic"])
424
+ st.markdown(
425
+ f"<div class='sec-title'>{len(st.session_state.comments)} Comentarios "
426
+ f"<span style='font-size:0.8rem; color:#cc0000;'>· {tox_cnt} detectados</span></div>",
427
+ unsafe_allow_html=True,
428
+ )
429
+
430
+ # Input de nuevo comentario
431
+ new_text = st.text_area(
432
+ "Escribe un comentario...",
433
+ height=80, label_visibility="collapsed",
434
+ key="comment_input",
435
+ placeholder="Escribe un comentario...",
436
+ )
437
+
438
+ # Análisis en tiempo real (solo cuando hay texto)
439
+ analysis = None
440
+ if new_text.strip():
441
+ svc = get_service(st.session_state.selected_model)
442
+ analysis = svc.predict(new_text)
443
+ pct = int(analysis["probability"] * 100)
444
+ color = "#cc0000" if pct >= 70 else "#f5a623" if pct >= 40 else "#00c853"
445
+ verdict = "TÓXICO" if analysis["is_toxic"] else "SEGURO"
446
+ v_color = "#cc0000" if analysis["is_toxic"] else "#00c853"
447
+ st.markdown(
448
+ f"<div class='tox-row'>"
449
+ f"<span>🔍 Analizando...</span>"
450
+ f"<span style='background:{v_color}; color:#fff; border-radius:10px;"
451
+ f"padding:1px 9px; font-size:0.72rem; font-weight:700;'>{verdict}</span>"
452
+ f"<span style='color:{color}; font-weight:600;'>Toxicidad: {pct}%</span>"
453
+ f"<div class='tox-bar-bg'>"
454
+ f"<div class='tox-bar-fill' style='width:{pct}%; background:{color};'></div>"
455
+ f"</div></div>",
456
+ unsafe_allow_html=True,
457
+ )
458
+
459
+ col_c, col_p = st.columns([1, 1])
460
+ with col_c:
461
+ if st.button("Cancelar", use_container_width=True):
462
+ st.rerun()
463
+ with col_p:
464
+ post = st.button("Comentar", type="primary", use_container_width=True)
465
+
466
+ # Procesar envío
467
+ if post and new_text.strip():
468
+ if analysis is None:
469
+ svc = get_service(st.session_state.selected_model)
470
+ analysis = svc.predict(new_text)
471
+
472
+ comment_obj = {
473
+ "user" : "usuario",
474
+ "initial" : "U",
475
+ "text" : new_text.strip(),
476
+ "time" : "ahora",
477
+ "is_toxic" : analysis["is_toxic"],
478
+ "probability": analysis["probability"],
479
+ "labels" : analysis["labels"],
480
+ }
481
+
482
+ if analysis["is_toxic"]:
483
+ # Guardar en pendiente y mostrar modal en el próximo render
484
+ st.session_state.pending_modal = comment_obj
485
+ st.rerun()
486
+ else:
487
+ # Publicar directamente
488
+ st.session_state.comments.append(comment_obj)
489
+ st.session_state.hub_history.insert(0, {
490
+ "Usuario" : "@usuario",
491
+ "Comentario": f'"{new_text.strip()[:45]}{"..." if len(new_text)>45 else ""}"',
492
+ "Score" : round(analysis["probability"], 2),
493
+ "Acción" : "✅ Aprobado",
494
+ })
495
+ st.rerun()
496
+
497
+ # ── Lista de comentarios ───────────────────────────────────────────
498
+ for c in reversed(st.session_state.comments):
499
+ is_tox = c["is_toxic"]
500
+ pct = int(c["probability"] * 100)
501
+ av_class = "c-avatar" if is_tox else "c-avatar safe"
502
+ badge = (
503
+ "<span class='badge badge-toxic'>TÓXICO</span>" if is_tox
504
+ else "<span class='badge badge-safe'>SEGURO</span>"
505
+ )
506
+ text_class = "c-text toxic" if is_tox else "c-text"
507
+ flagged = "<div class='c-flagged'>🚩 Flagged for review</div>" if is_tox else ""
508
+
509
+ # html.escape() protege contra caracteres que rompen el HTML
510
+ safe_text = html.escape(c["text"])
511
+ safe_user = html.escape(c["user"])
512
+ initial = html.escape(c.get("initial", c["user"][0].upper()))
513
+
514
+ st.markdown(
515
+ f"<div class='comment-wrap'>"
516
+ f" <div class='{av_class}'>{initial}</div>"
517
+ f" <div class='c-body'>"
518
+ f" <div class='c-header'>"
519
+ f" <span class='c-user'>@{safe_user}</span>"
520
+ f" <span class='c-time'>{c['time']}</span>"
521
+ f" {badge}"
522
+ f" </div>"
523
+ f" <div class='{text_class}'>{safe_text}</div>"
524
+ f" {flagged}"
525
+ f" </div>"
526
+ f"</div>",
527
+ unsafe_allow_html=True,
528
+ )
529
+
530
+ # ── Columna derecha ────────────────────────────────────────────────────
531
+ with col_right:
532
+ st.markdown("**Sugeridos**")
533
+ suggested = [
534
+ ("🤖", "Understanding Transformer Models...", "Neural Systems", "89k · 1 día"),
535
+ ("🎓", "The Future of Content Moderation", "Tech Ethics Pro", "1.4M · 2 sem"),
536
+ ("📡", "Signal vs Noise: SignalMod Deep Dive","SignalMod AI", "250k · 3 días"),
537
+ ("💡", "Why AI Moderation is Harder Than...", "Ethics in Code", "45k · 5 h"),
538
+ ("🔬", "Hate Speech Detection 2024", "AI Research Lab", "12k · 1 sem"),
539
+ ]
540
+ for emoji, title, ch, meta in suggested:
541
+ st.markdown(
542
+ f"<div class='sug-card'>"
543
+ f" <div class='sug-thumb'>{emoji}</div>"
544
+ f" <div>"
545
+ f" <div class='sug-title'>{html.escape(title)}</div>"
546
+ f" <div class='sug-ch'>{html.escape(ch)}</div>"
547
+ f" <div class='sug-meta'>{html.escape(meta)}</div>"
548
+ f" </div>"
549
+ f"</div>",
550
+ unsafe_allow_html=True,
551
+ )
552
+
553
+
554
+ # ══════════════════════════════════════════════════════════════════════════════
555
+ # MODERATOR HUB
556
+ # ══════════════════════════════════════════════════════════════════════════════
557
+ def render_hub():
558
+ try:
559
+ import plotly.graph_objects as go
560
+ except ImportError:
561
+ st.error("Instala plotly: pip install plotly")
562
+ return
563
+
564
+ st.markdown("## 📊 Panel de Estadísticas")
565
+
566
+ # ── Cards de configuración ──────────────────────────────────────────────
567
+ model_short = st.session_state.selected_model.split("(")[0].strip()
568
+ c1, c2, c3 = st.columns(3)
569
+ for col, label, val in [
570
+ (c1, "MODEL ARCHITECTURE", model_short),
571
+ (c2, "CONFIDENCE THRESHOLD", f"{st.session_state.threshold:.2f} Alpha"),
572
+ (c3, "LANGUAGE COVERAGE", "English"),
573
+ ]:
574
+ with col:
575
+ st.markdown(
576
+ f"<div class='hub-card'>"
577
+ f"<div class='hub-kpi-label'>{label}</div>"
578
+ f"<div style='font-weight:600; font-size:0.95rem; color:#0f0f0f;'>"
579
+ f"{html.escape(str(val))}</div></div>",
580
+ unsafe_allow_html=True,
581
+ )
582
+
583
+ st.write("")
584
+
585
+ # ── KPIs ────────��──────────────────────────────────────────────────────
586
+ total = len(st.session_state.comments) + 100
587
+ tox_cnt = sum(1 for c in st.session_state.comments if c["is_toxic"]) + 5
588
+ tox_rate = tox_cnt / total * 100
589
+ m1, m2, m3 = st.columns(3)
590
+ m1.metric("💬 Total comentarios", f"{total:,}", "+12%")
591
+ m2.metric("☠️ Tasa de toxicidad", f"{tox_rate:.1f}%",
592
+ f"+0.8%", delta_color="inverse")
593
+ m3.metric("🎯 F1 Score", "0.7579", "Stable")
594
+
595
+ st.divider()
596
+
597
+ # ── Gráficos ───────────────────────────────────────────────────────────
598
+ gcol, pcol = st.columns([2.2, 1])
599
+
600
+ with gcol:
601
+ days = ["Lun","Mar","Mié","Jue","Vie","Sáb","Dom"]
602
+ vals = [random.randint(30, 80) for _ in days]
603
+ vals[3] = max(vals) + 25
604
+ colors = ["#cc0000" if i == 3 else "#b3c6ff" for i in range(7)]
605
+ fig = go.Figure(go.Bar(x=days, y=vals, marker_color=colors, width=0.55))
606
+ fig.update_layout(
607
+ title="Tendencias de Toxicidad (7D)",
608
+ paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
609
+ margin=dict(l=20, r=20, t=40, b=20), height=260,
610
+ font=dict(size=11, color="#0f0f0f"),
611
+ )
612
+ fig.update_yaxes(showgrid=True, gridcolor="#f0f0f0", zeroline=False)
613
+ fig.update_xaxes(showgrid=False)
614
+ st.plotly_chart(fig, use_container_width=True)
615
+
616
+ with pcol:
617
+ fig2 = go.Figure(go.Pie(
618
+ labels=["Hate Speech","Insulto","Agresividad"],
619
+ values=[45, 35, 20],
620
+ hole=0.58,
621
+ marker_colors=["#cc0000","#0f0f0f","#909090"],
622
+ textfont_size=11,
623
+ ))
624
+ fig2.update_layout(
625
+ title="Categorías",
626
+ paper_bgcolor="#ffffff",
627
+ margin=dict(l=10, r=10, t=40, b=10), height=260,
628
+ legend=dict(font=dict(size=10), orientation="v"),
629
+ font=dict(size=11, color="#0f0f0f"),
630
+ )
631
+ st.plotly_chart(fig2, use_container_width=True)
632
+
633
+ # ── Historial ──────────────────────────────────────────────────────────
634
+ st.markdown("### Historial Reciente")
635
+ df = pd.DataFrame(st.session_state.hub_history)
636
+ if not df.empty:
637
+ st.dataframe(
638
+ df, use_container_width=True, hide_index=True,
639
+ column_config={
640
+ "Score": st.column_config.ProgressColumn(
641
+ "Score", min_value=0, max_value=1, format="%.2f"
642
+ )
643
+ },
644
+ )
645
+
646
+
647
+ # ══════════════════════════════════════════════════════════════════════════════
648
+ # SETTINGS
649
+ # ══════════════════════════════════════════════════════════════════════════════
650
+ def render_settings():
651
+ st.markdown("## ⚙️ Ajustes")
652
+
653
+ # ── Selección de modelo ─────────────────────────────────────────────────
654
+ st.markdown("### 🤖 Modelo de detección",)
655
+ st.caption(
656
+ "Los modelos HuggingFace se descargan la primera vez (~300–600 MB). "
657
+ "Requieren: `pip install transformers torch sentencepiece`"
658
+ )
659
+ st.write("")
660
+
661
+ # Usamos st.radio para la selección — sin bugs de HTML
662
+ model_names = list(AVAILABLE_MODELS.keys())
663
+ current_idx = model_names.index(st.session_state.selected_model) \
664
+ if st.session_state.selected_model in model_names else 0
665
+
666
+ chosen = st.radio(
667
+ "Seleccionar modelo",
668
+ model_names,
669
+ index=current_idx,
670
+ label_visibility="collapsed",
671
+ )
672
+
673
+ if chosen != st.session_state.selected_model:
674
+ st.session_state.selected_model = chosen
675
+ st.rerun()
676
+
677
+ # Ficha del modelo seleccionado
678
+ info = AVAILABLE_MODELS[st.session_state.selected_model]
679
+ st.markdown(
680
+ f"<div class='model-card active'>"
681
+ f"<div class='model-card-name'>{info['icon']} {html.escape(st.session_state.selected_model)}</div>"
682
+ f"<div class='model-card-desc'>{html.escape(info['description'])}</div>"
683
+ f"<div style='margin-top:8px;'>"
684
+ f"<span class='model-pill'>⚡ {html.escape(info['speed'])}</span>"
685
+ f"<span class='model-pill'>🎯 {html.escape(info['accuracy'])}</span>"
686
+ f"<span class='model-pill'>📦 {html.escape(info['requires'])}</span>"
687
+ f"</div></div>",
688
+ unsafe_allow_html=True,
689
+ )
690
+
691
+ # Info sobre modelo fine-tuneado
692
+ if st.session_state.selected_model == "Modelo fine-tuneado (local)":
693
+ path = PROJECT_ROOT / "models" / "finetuned_hf"
694
+ if path.exists():
695
+ st.success(f"✅ Modelo encontrado en `{path}`")
696
+ else:
697
+ st.warning(
698
+ f"⚠️ No se encontró el modelo en `{path}`. "
699
+ f"Ejecuta el **notebook 08** para generar el modelo fine-tuneado."
700
+ )
701
+
702
+ st.divider()
703
+
704
+ # ── Umbral de confianza ─────────────────────────────────────────────────
705
+ st.markdown("### 🎚️ Umbral de confianza")
706
+ st.caption("Probabilidad mínima para marcar un comentario como tóxico.")
707
+
708
+ new_thr = st.slider(
709
+ "Umbral",
710
+ min_value=0.3, max_value=0.9, step=0.05,
711
+ value=st.session_state.threshold,
712
+ label_visibility="collapsed",
713
+ format="%.2f",
714
+ )
715
+ if new_thr != st.session_state.threshold:
716
+ st.session_state.threshold = new_thr
717
+ st.info(f"Umbral actualizado: **{new_thr:.2f}**")
718
+
719
+ ta, tb = st.columns(2)
720
+ ta.info(f"⬇️ **{new_thr:.2f}** bajo → más FP (más censura)", icon="⚠️")
721
+ tb.info(f"⬆️ **{new_thr:.2f}** alto → más FN (más escapes)", icon="⚠️")
722
+
723
+ st.divider()
724
+
725
+ # ── Test rápido ─────────────────────────────────────────────────────────
726
+ st.markdown("### 🧪 Probar modelo")
727
+ test_txt = st.text_input(
728
+ "Texto a analizar",
729
+ placeholder="Ej: This is absolutely stupid and racist...",
730
+ label_visibility="collapsed",
731
+ )
732
+ if st.button("Analizar", type="primary") and test_txt.strip():
733
+ with st.spinner("Analizando..."):
734
+ svc = get_service(st.session_state.selected_model)
735
+ res = svc.predict(test_txt)
736
+
737
+ pct = int(res["probability"] * 100)
738
+ verdict = "🔴 TÓXICO" if res["is_toxic"] else "🟢 SEGURO"
739
+ st.markdown(f"**{verdict}** — {pct}% de toxicidad")
740
+ st.progress(res["probability"])
741
+ if res["labels"]:
742
+ st.markdown(f"**Categorías:** {', '.join(res['labels'])}")
743
+ if "error" in res:
744
+ st.error(f"Error: {res['error']}")
745
+ st.caption(f"Modelo: {res['model_used']}")
746
+
747
+
748
+ # ══════════════════════════════════════════════════════════════════════════════
749
+ # MAIN
750
+ # ══════════════════════════════════════════════════════════════════════════════
751
+ def main():
752
+ render_sidebar()
753
+
754
+ page = st.session_state.page
755
+ if page == "Home":
756
+ render_home()
757
+ elif page == "Moderator Hub":
758
+ render_hub()
759
+ elif page == "Settings":
760
+ render_settings()
761
+
762
+
763
+ if __name__ == "__main__":
764
+ main()
src/service/model_service.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ src/services/model_service.py
3
+
4
+ Servicio centralizado de predicción de toxicidad.
5
+
6
+ Modelos soportados:
7
+ local → models/final_model.joblib (LR + TF-IDF, instantáneo)
8
+ hf_remote → HuggingFace Hub (requiere internet + transformers)
9
+ hf_local → modelo HF fine-tuneado localmente (notebook 08)
10
+
11
+ Instalación para modelos HF:
12
+ pip install transformers torch sentencepiece accelerate
13
+ """
14
+
15
+ import re
16
+ import yaml
17
+ import joblib
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ # ─── Catálogo de modelos ──────────────────────────────────────────────────────
22
+ AVAILABLE_MODELS = {
23
+ "LR + TF-IDF (local)": {
24
+ "type" : "local",
25
+ "icon" : "⚡",
26
+ "description": "Modelo del proyecto. Sin GPU, instantáneo.",
27
+ "speed" : "< 50ms",
28
+ "accuracy" : "F1 0.76",
29
+ "requires" : "Solo joblib",
30
+ },
31
+ "DistilBERT Toxicity": {
32
+ "type" : "hf_remote",
33
+ "icon" : "🤖",
34
+ "model_id" : "martin-ha/toxic-comment-model",
35
+ "description": "DistilBERT fine-tuned en comentarios tóxicos.",
36
+ "speed" : "~200ms CPU",
37
+ "accuracy" : "F1 0.85",
38
+ "requires" : "transformers torch",
39
+ },
40
+ "toxic-bert (multilabel)": {
41
+ "type" : "hf_remote",
42
+ "icon" : "🧠",
43
+ "model_id" : "unitary/toxic-bert",
44
+ "description": "BERT multi-label (Jigsaw). Detecta 6 categorías.",
45
+ "speed" : "~400ms CPU",
46
+ "accuracy" : "F1 0.88",
47
+ "requires" : "transformers torch",
48
+ },
49
+ "RoBERTa Toxicity": {
50
+ "type" : "hf_remote",
51
+ "icon" : "🔬",
52
+ "model_id" : "s-nlp/roberta_toxicity_classifier",
53
+ "description": "RoBERTa fine-tuned para toxicidad general.",
54
+ "speed" : "~350ms CPU",
55
+ "accuracy" : "F1 0.87",
56
+ "requires" : "transformers torch",
57
+ },
58
+ "Modelo fine-tuneado (local)": {
59
+ "type" : "hf_local",
60
+ "icon" : "✨",
61
+ "model_path" : "models/finetuned_hf",
62
+ "description": "Tu modelo fine-tuneado en el notebook 08.",
63
+ "speed" : "Depende del hardware",
64
+ "accuracy" : "A evaluar",
65
+ "requires" : "transformers torch",
66
+ },
67
+ }
68
+
69
+ HF_LABEL_MAP = {
70
+ "toxic": "Tóxico", "severe_toxic": "Muy ofensivo",
71
+ "obscene": "Obsceno", "threat": "Amenaza",
72
+ "insult": "Insulto", "identity_hate": "Odio racial",
73
+ "label_1": "Tóxico",
74
+ }
75
+
76
+ _KEYWORD_LABELS = {
77
+ "Insulto" : ["idiot","stupid","dumb","fool","moron","loser"],
78
+ "Odio racial": ["thug","racist","race","criminal"],
79
+ "Amenaza" : ["kill","shoot","die","dead","hurt","attack"],
80
+ "Obsceno" : ["fuck","shit","ass","bitch","cunt","bastard"],
81
+ "Agresividad": ["hate","despise","disgusting","pathetic","worthless"],
82
+ }
83
+
84
+
85
+ def _labels_from_keywords(text: str, probability: float) -> list:
86
+ t = text.lower()
87
+ found = [lbl for lbl, kws in _KEYWORD_LABELS.items() if any(k in t for k in kws)]
88
+ return found if found else (["Contenido ofensivo"] if probability >= 0.5 else [])
89
+
90
+
91
+ class _FallbackPreprocessor:
92
+ _SW = {"the","a","an","and","or","but","in","on","at","to","for",
93
+ "of","with","is","it","this","that","are","was","be","have",
94
+ "has","he","she","they","we","you","i","not","do","did",
95
+ "will","can","would","should","could","from","by","as","if"}
96
+ def transform(self, text):
97
+ t = re.sub(r"http\S+|www\.\S+|@\w+", " ", str(text).lower())
98
+ t = re.sub(r"[^\x00-\x7F]+", " ", t)
99
+ t = re.sub(r"[^a-z\s]", " ", t)
100
+ t = re.sub(r"\s+", " ", t).strip()
101
+ return " ".join(w for w in t.split() if w not in self._SW and len(w) > 2)
102
+
103
+
104
+ class ModelService:
105
+ def __init__(self, model_name: str, project_root: Optional[Path] = None):
106
+ self.model_name = model_name
107
+ self.cfg = AVAILABLE_MODELS.get(model_name) or list(AVAILABLE_MODELS.values())[0]
108
+ self.project_root = project_root or Path.cwd()
109
+ self._model = None
110
+ self._preprocessor = None
111
+
112
+ def _get_model(self):
113
+ if self._model is None:
114
+ t = self.cfg["type"]
115
+ if t == "local":
116
+ self._load_local()
117
+ elif t == "hf_remote":
118
+ self._load_hf(self.cfg["model_id"])
119
+ elif t == "hf_local":
120
+ path = self.project_root / self.cfg["model_path"]
121
+ if not path.exists():
122
+ raise FileNotFoundError(
123
+ f"Modelo no encontrado en {path}. Ejecuta el notebook 08 primero."
124
+ )
125
+ self._load_hf(str(path))
126
+ return self._model
127
+
128
+ def _load_local(self):
129
+ for name in ["final_model.joblib","lr_tuned.joblib",
130
+ "lr_baseline.joblib","best_ensemble.joblib"]:
131
+ p = self.project_root / "models" / name
132
+ if p.exists():
133
+ self._model = joblib.load(p)
134
+ break
135
+ if self._model is None:
136
+ raise FileNotFoundError(f"No hay modelo en {self.project_root / 'models'}")
137
+ try:
138
+ import sys; sys.path.insert(0, str(self.project_root))
139
+ from src.features.text_preprocessor import TextPreprocessor
140
+ self._preprocessor = TextPreprocessor(
141
+ config_path=str(self.project_root / "configs" / "features.yaml")
142
+ )
143
+ except Exception:
144
+ self._preprocessor = _FallbackPreprocessor()
145
+
146
+ def _load_hf(self, model_id_or_path: str):
147
+ try:
148
+ from transformers import pipeline as hf_pipeline
149
+ except ImportError:
150
+ raise ImportError("Instala: pip install transformers torch sentencepiece")
151
+ self._model = hf_pipeline(
152
+ "text-classification", model=model_id_or_path,
153
+ return_all_scores=True, truncation=True, max_length=512,
154
+ )
155
+
156
+ def predict(self, text: str) -> dict:
157
+ if not text or not text.strip():
158
+ return {"is_toxic": False, "probability": 0.0,
159
+ "labels": [], "model_used": self.model_name}
160
+ try:
161
+ model = self._get_model()
162
+ if self.cfg["type"] == "local":
163
+ return self._pred_local(text, model)
164
+ return self._pred_hf(text, model)
165
+ except Exception as e:
166
+ return {"is_toxic": False, "probability": 0.0,
167
+ "labels": [], "model_used": self.model_name, "error": str(e)}
168
+
169
+ def _pred_local(self, text, model):
170
+ clean = self._preprocessor.transform(text) or text
171
+ proba = float(model.predict_proba([clean])[0][1])
172
+ tox = proba >= 0.5
173
+ return {"is_toxic": tox, "probability": proba,
174
+ "labels": _labels_from_keywords(text, proba) if tox else [],
175
+ "model_used": self.model_name}
176
+
177
+ def _pred_hf(self, text, pipeline_fn):
178
+ raw = pipeline_fn(text[:512])
179
+ smap = {s["label"].lower(): s["score"] for s in (raw[0] if isinstance(raw[0], list) else raw)}
180
+ for key in ("label_1","toxic","toxic_1"):
181
+ if key in smap:
182
+ proba = smap[key]; break
183
+ else:
184
+ neg = {"label_0","non_toxic","not_toxic","not toxic"}
185
+ vals = [v for k,v in smap.items() if k not in neg]
186
+ proba = max(vals) if vals else 0.0
187
+ tox = proba >= 0.5
188
+ labels = []
189
+ if tox:
190
+ for k,v in smap.items():
191
+ if k not in ("label_0","non_toxic") and v >= 0.35:
192
+ friendly = HF_LABEL_MAP.get(k, k.replace("_"," ").title())
193
+ if "no tóxico" not in friendly.lower():
194
+ labels.append(friendly)
195
+ if not labels:
196
+ labels = ["Contenido ofensivo"]
197
+ return {"is_toxic": tox, "probability": proba,
198
+ "labels": labels, "model_used": self.model_name}
199
+
200
+ @staticmethod
201
+ def get_available_models(): return AVAILABLE_MODELS
202
+ def get_model_info(self): return self.cfg