File size: 4,938 Bytes
6cda091
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
"""
src/features/text_preprocessor.py

Pipeline de preprocesamiento NLP.
Traducción directa del notebook 02 a código de producción.

Pasos:
    1. Lowercase
    2. Regex: URLs, @menciones, \\xa0, apostrofes, números
    3. spaCy: lematización (en_core_web_sm)
    4. NLTK: filtrado stopwords english + custom

Uso:
    preprocessor = TextPreprocessor()
    clean_series = preprocessor.transform(df["Text"])
    clean_text   = preprocessor.transform("texto crudo aqui")
"""

import re
import yaml
import nltk
import spacy
import pandas as pd
from pathlib import Path
from nltk.corpus import stopwords
from src.utils.logger import get_logger

logger = get_logger(__name__)

# Descargar recursos NLTK si no existen
for resource in ["stopwords", "punkt"]:
    nltk.download(resource, quiet=True)


class TextPreprocessor:
    """
    Pipeline NLP para hate speech detection.
    Lee su configuración de configs/features.yaml.
    """

    # Stopwords custom: palabras frecuentes sin valor discriminante
    # en el dominio YouTube. No son stopwords generales.
    CUSTOM_STOPWORDS = {
        "youtube", "video", "watch", "like", "comment",
        "channel", "click", "subscribe", "link",
    }

    def __init__(self, config_path: str = "configs/features.yaml"):
        # Cargar config
        with open(config_path) as f:
            cfg = yaml.safe_load(f)["preprocessing"]
        self.cfg = cfg

        # Stopwords: NLTK + custom
        self.stop_words = set(stopwords.words("english")) | self.CUSTOM_STOPWORDS
        self.min_len = cfg.get("min_token_length", 2)

        # Cargar modelo spaCy
        # disable=["parser","ner"] → solo usamos el lemmatizer, más rápido
        self.nlp = spacy.load("en_core_web_sm", disable=["parser", "ner"])
        logger.info(f"TextPreprocessor iniciado — spaCy {self.nlp.meta['version']}")

    # ── Pasos individuales ────────────────────────────────────────────────────

    def _lowercase(self, text: str) -> str:
        """Paso 1: minúsculas. 'BLACK' y 'black' son la misma feature."""
        return str(text).lower()

    def _clean_regex(self, text: str) -> str:
        """
        Paso 2: elimina ruido estructural con regex.
        Orden importante: primero lo más específico, luego lo general.
        """
        text = re.sub(r"http\S+|www\.\S+", "", text)   # URLs
        text = re.sub(r"@\w+", "", text)                # @menciones
        text = re.sub(r"[\n\t\r]", " ", text)           # saltos de línea
        text = re.sub(r"[^\x00-\x7F]+", " ", text)      # \xa0, emojis
        text = re.sub(r"'", "", text)                   # apóstrofes
        text = re.sub(r"\b\d+\b", "", text)             # números solos
        text = re.sub(r"\s+", " ", text)                # espacios múltiples
        return text.strip()

    def _lemmatize(self, text: str) -> str:
        """
        Paso 3+4: lematización con spaCy + filtrado de stopwords con NLTK.

        Por qué spaCy para lematizar:
            Entiende gramática: 'running'→'run', 'cops'→'cop'
            Un stemmer de NLTK simplemente corta: 'running'→'runn'

        Por qué NLTK para stopwords:
            Lista curada de 179 palabras funcionales.
            Más fácil de personalizar que la lista interna de spaCy.

        DECISIÓN del EDA: NO eliminar 'black','white','police','cop'
            → Aparecen en ambas clases con contexto distinto.
              El modelo necesita verlas para aprender por bigrams.
        """
        doc = self.nlp(text)
        tokens = [
            token.lemma_
            for token in doc
            if not token.is_punct
            and not token.is_space
            and len(token.text) >= self.min_len
            and token.lemma_ not in self.stop_words
        ]
        return " ".join(tokens)

    def _transform_one(self, text: str) -> str:
        text = self._lowercase(text)
        text = self._clean_regex(text)
        text = self._lemmatize(text)
        return text

    # ── Interfaz pública ──────────────────────────────────────────────────────

    def transform(self, data) -> str | pd.Series:
        """
        Preprocesa un texto o una Serie completa.

        Args:
            data: str o pd.Series con textos crudos.

        Returns:
            str o pd.Series con textos limpios y lematizados.
        """
        if isinstance(data, pd.Series):
            logger.info(f"Preprocesando {len(data)} textos...")
            result = data.apply(self._transform_one)
            empty  = (result == "").sum()
            if empty > 0:
                logger.warning(f"  {empty} textos quedaron vacíos tras limpieza")
            return result
        return self._transform_one(data)