Merge pull request #18 from Bootcamp-IA-P6/v2
Browse filesAdd end-to-end NLP toxicity classification pipeline
- .gitattributes +1 -0
- .gitignore +66 -0
- configs/best_params.yaml +15 -0
- configs/features.yaml +35 -0
- configs/models.yaml +34 -0
- configs/pipeline.yaml +19 -0
- data/processed/v2/comments_with_stats.csv +0 -0
- models/.gitkeep +0 -0
- models/final_model.joblib +3 -0
- notebooks/01_eda_v2.ipynb +0 -0
- notebooks/02_preprocessing_v2.ipynb +814 -0
- notebooks/03_vectorization_v2.ipynb +0 -0
- notebooks/04_baseline_v2.ipynb +0 -0
- notebooks/05_ensemble_v2.ipynb +0 -0
- notebooks/06_tuning_clean_v2.ipynb +956 -0
- notebooks/07_augmentation_clean_v2.ipynb +0 -0
- notebooks/08_transformers_clean_v2.ipynb +0 -0
- reports/v2/01_distribucion_target.png +3 -0
- reports/v2/02_longitud_texto.png +3 -0
- reports/v2/03_wordclouds.png +3 -0
- reports/v2/04_correlacion_sublabels.png +3 -0
- reports/v2/05_multilabel_overlap.png +3 -0
- reports/v2/06_toxicidad_por_video.png +3 -0
- reports/v2/07_tokens_comparativa.png +3 -0
- reports/v2/08_tfidf_top_features.png +3 -0
- reports/v2/09_cv_metrics.png +3 -0
- reports/v2/10_baseline_confusion_roc.png +3 -0
- reports/v2/11_lr_coeficientes.png +3 -0
- reports/v2/12_ensemble_comparativa.png +3 -0
- reports/v2/13_best_model_test.png +3 -0
- reports/v2/14_optuna_comparativa.png +3 -0
- reports/v2/15_augmentation_comparativa.png +3 -0
- reports/v2/16_augmentation_confusion.png +3 -0
- reports/v2/nb08_comparativa_final.png +3 -0
- reports/v2/nb08_distilbert_base_cm.png +3 -0
- reports/v2/nb08_distilbert_toxic_(fine-tuned)_cm.png +3 -0
- reports/v2/nb08_roberta_hate_cm.png +3 -0
- src/app/app.py +764 -0
- 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/AmoqNlvtMsBQRkTa9VZGIiIiIiIiIiIg6Nc5JSEREREREREREpHFMEhIREREREREREWkck4REREREREREREQaxyQhERERERERERGRxjFJSEREREREREREpHFMEhIREREREREREWkck4REREREREREREQaxyQhERERERERERGRxjFJSETUyeTn50NRFFy7dq3dj/2rX/0Kw4cPb/fjEhEREXUEjru+dvbsWSiKgqKiIgDtGxtFUbBu3bo2Pw4RNY1JQiLqdKZPnw5FUbB8+XKf5evWrYOiKB1Uq66lsw06iYiIqHPiuOv2heq4a+zYsSgvL0e3bt3a/Fjl5eWYNGlSmx+HiJrGJCERdUphYWH47W9/i6tXr3Z0VYLicDg6ugpERERELcJxF/ljNBqRmJjYLsnixMREmEymNj8OETWNSUIi6pQyMzORmJiIZcuWBSzj71vbl19+Gampqd6fp0+fjqlTp+KFF15AQkICoqOj8dxzz8HlcmHRokXo3r077rjjDqxcudJnP+fPn8f3v/99REdHo3v37pgyZQrOnj3baL/PP/88kpKSMHDgQADAkSNH8E//9E8wm82IjY3FzJkzYbVam2zrxo0bMWDAAJjNZnzzm9/0OU69Xbt2Yfz48TCbzejduzfmzZuHmpoav/tbtWoVnn32WRw6dAiKokBRFKxatQoAUFZWhilTpiAiIgJRUVH4/ve/j4sXLwas2+nTp9G3b1/MnTsXIgK73Y6nnnoKvXr1Qnh4OMaMGYP8/HyfY0dHR2Pz5s0YPHgwIiIikJ2djfLycm+Z/Px8jB49GuHh4YiOjsa4ceNw7ty5JmNEREREbYfjLl8cd3297c2PG9cfb/369Rg4cCAsFgu++93vwmaz4S9/+QtSU1MRExODefPmwe12e/eTmpqKX//613jkkUcQHh6OXr16YcWKFT7Havi4cXOfLceTRG2DSUIi6pR0Oh1eeOEFvPbaa/jiiy9ua1+ffvop/vGPf2DHjh146aWXsHTpUvzzP/8zYmJisHfvXsyePRuzZs3yHsfpdCIrKwuRkZHYuXMndu/e7R103fzN9datW1FcXIwtW7Zg/fr1qKmpQVZWFmJiYrBv3z689957+OSTTzB37tyAdTt//jweeughPPjggygqKsJPfvITLFmyxKfM6dOnkZ2djYcffhiHDx/GmjVrsGvXroD7nTZtGhYuXIihQ4eivLwc5eXlmDZtGjweD6ZMmYIrV65g+/bt2LJlC86cOYNp06b53c/hw4dx33334dFHH8Uf/vAHKIqCuXPnoqCgAKtXr8bhw4fxve99D9nZ2SgpKfFuZ7PZ8Pvf/x5vvvkmduzYgbKyMjz11FMAAJfLhalTp+L+++/H4cOHUVBQgJkzZ/JxJiIiog7EcdfXOO5qms1mw6uvvorVq1dj06ZNyM/Px3e+8x1s3LgRGzduxJtvvok//vGP+Nvf/uaz3Ysvvoj09HQcPHgQS5Yswfz587Flyxa/x2jus+V4kqgNCRFRJ5ObmytTpkwREZF7771XZsyYISIia9eulZtPW0uXLpX09HSfbf/jP/5DUlJSfPaVkpIibrfbu2zgwIEyfvx4788ul0vCw8Pl3XffFRGRN998UwYOHCgej8dbxm63i9lsls2bN3v3m5CQIHa73VvmT3/6k8TExIjVavUu27Bhg6iqKhUVFX7bmpeXJ0OGDPFZtnjxYgEgV69eFRGRxx9/XGbOnOlTZufOnaKqqtTW1vrdr7/YfPzxx6LT6aSsrMy77NixYwJACgsLfbbbvXu3xMTEyO9//3tv2XPnzolOp5MLFy747HfixImSl5cnIiIrV64UAHLq1Cnv+hUrVkhCQoKIiFy+fFkASH5+vt96ExERUfviuIvjrnqlpaUCQA4ePCgiItu2bfOJjb/jzZo1SywWi1RXV3uXZWVlyaxZs7w/p6SkSHZ2ts+xpk2bJpMmTfL+DEDWrl0rIs1/thxPErUd3klIRJ3ab3/7W/zlL3/BiRMnWryPoUOHQlW/Pt0lJCQgLS3N+7NOp0NsbCwqKysBAIcOHcKpU6cQGRmJiIgIREREoHv37qirq8Pp06e926WlpcFoNHp/PnHiBNLT0xEeHu5dNm7cOHg8HhQXF/ut24kTJzBmzBifZRkZGT4/Hzp0CKtWrfLWJSIiAllZWfB4PCgtLQ06DidOnEDv3r3Ru3dv77IhQ4YgOjraJ75lZWX41re+hWeeeQYLFy70Lj9y5AjcbjcGDBjgU5ft27f7xMVisaBfv37en3v27OmNbffu3TF9+nRkZWXhwQcfxCuvvOLzSAwRERF1HI67OO5qTsPjJSQkIDU1FRERET7L6utQr2GcMzIyAvaz5j5bjieJ2o6+oytARNSUCRMmICsrC3l5eZg+fbrPOlVVISI+y5xOZ6N9GAwGn58VRfG7zOPxAACsVitGjhyJt99+u9G+evTo4f3/zQOXtmS1WjFr1izMmzev0brk5ORWP16PHj2QlJSEd999FzNmzEBUVJS3HjqdDgcOHIBOp/PZ5uaBob/Y3vw5rVy5EvPmzcOmTZuwZs0a/OIXv8CWLVtw7733tnpbiIiIKHgcd3Hc1Zxb/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
|
reports/v2/02_longitud_texto.png
ADDED
|
Git LFS Details
|
reports/v2/03_wordclouds.png
ADDED
|
Git LFS Details
|
reports/v2/04_correlacion_sublabels.png
ADDED
|
Git LFS Details
|
reports/v2/05_multilabel_overlap.png
ADDED
|
Git LFS Details
|
reports/v2/06_toxicidad_por_video.png
ADDED
|
Git LFS Details
|
reports/v2/07_tokens_comparativa.png
ADDED
|
Git LFS Details
|
reports/v2/08_tfidf_top_features.png
ADDED
|
Git LFS Details
|
reports/v2/09_cv_metrics.png
ADDED
|
Git LFS Details
|
reports/v2/10_baseline_confusion_roc.png
ADDED
|
Git LFS Details
|
reports/v2/11_lr_coeficientes.png
ADDED
|
Git LFS Details
|
reports/v2/12_ensemble_comparativa.png
ADDED
|
Git LFS Details
|
reports/v2/13_best_model_test.png
ADDED
|
Git LFS Details
|
reports/v2/14_optuna_comparativa.png
ADDED
|
Git LFS Details
|
reports/v2/15_augmentation_comparativa.png
ADDED
|
Git LFS Details
|
reports/v2/16_augmentation_confusion.png
ADDED
|
Git LFS Details
|
reports/v2/nb08_comparativa_final.png
ADDED
|
Git LFS Details
|
reports/v2/nb08_distilbert_base_cm.png
ADDED
|
Git LFS Details
|
reports/v2/nb08_distilbert_toxic_(fine-tuned)_cm.png
ADDED
|
Git LFS Details
|
reports/v2/nb08_roberta_hate_cm.png
ADDED
|
Git LFS Details
|
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""{html.escape(text[:140])}{'...' if len(text)>140 else ''}"</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
|