Minería de Texto a Escala — Sentiment140

Demostración · CC3084 · Semestre 02, 2026

Versión actualizada: comparación de 12 combinaciones — stemming/lematización × Bag of Words/TF-IDF × LogisticRegression/LinearSVC/ComplementNB.

Este notebook reproduce el flujo completo de minería de texto sobre Sentiment140. La estructura sigue la secuencia de clase: primero hacemos visible cada etapa del preprocesamiento y después usamos el resultado para representar, entrenar y evaluar modelos.

Sentiment140 contiene aproximadamente 1.6 millones de tweets en inglés. Sus etiquetas fueron generadas mediante distant supervision con emoticones, por lo que las métricas deben interpretarse como resultados sobre etiquetas ruidosas.

Preparación y carga de los datos

El archivo crudo se descarga con src/download_dataset.py. Se lee con latin-1, sin encabezados, y se toma una muestra reproducible para que los modelos puedan ejecutarse en un computador personal. La auditoría inicial sí recorre el archivo completo por chunks.

Código
from pathlib import Path
import re
import warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.dummy import DummyClassifier
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.naive_bayes import ComplementNB
from sklearn.svm import LinearSVC
from sklearn.metrics import accuracy_score, classification_report, ConfusionMatrixDisplay, f1_score, precision_recall_fscore_support
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline

try:
    from nltk.corpus import stopwords
    from nltk.stem import SnowballStemmer, WordNetLemmatizer
except ImportError:
    stopwords = SnowballStemmer = WordNetLemmatizer = None

warnings.filterwarnings('ignore')
SEED = 42
ROOT = Path.cwd().parent if Path.cwd().name == 'notebooks' else Path.cwd()
DATA = ROOT / 'data' / 'raw' / 'training.1600000.processed.noemoticon.csv'
COLS = ['target', 'ids', 'date', 'flag', 'user', 'text']
if not DATA.exists():
    raise FileNotFoundError('Falta el CSV. Ejecuta python src/download_dataset.py')

def leer_muestra(n=100_000, seed=SEED):
    partes = []
    for chunk in pd.read_csv(DATA, encoding='latin-1', names=COLS, header=None,
                             chunksize=100_000, quotechar='"'):
        partes.append(chunk)
    todo = pd.concat(partes, ignore_index=True)
    return todo.sample(min(n, len(todo)), random_state=seed).reset_index(drop=True)

df = leer_muestra()
print('Muestra:', df.shape)
display(df.head())
Muestra: (100000, 6)
target ids date flag user text
0 0 2200003196 Tue Jun 16 18:18:12 PDT 2009 NO_QUERY LaLaLindsey0609 @chrishasboobs AHHH I HOPE YOUR OK!!!
1 0 1467998485 Mon Apr 06 23:11:14 PDT 2009 NO_QUERY sexygrneyes @misstoriblack cool , i have no tweet apps  fo...
2 0 2300048954 Tue Jun 23 13:40:11 PDT 2009 NO_QUERY sammydearr @TiannaChaos i know  just family drama. its la...
3 0 1993474027 Mon Jun 01 10:26:07 PDT 2009 NO_QUERY Lamb_Leanne School email won't open  and I have geography ...
4 0 2256550904 Sat Jun 20 12:56:51 PDT 2009 NO_QUERY yogicerdito upper airways problem
Código
def auditoria_por_chunks():
    total, etiquetas, duplicados = 0, {}, 0
    longitudes = []
    for chunk in pd.read_csv(DATA, encoding='latin-1', names=COLS, header=None,
                             chunksize=100_000, quotechar='"'):
        total += len(chunk)
        for etiqueta, cantidad in chunk.target.value_counts().items():
            etiquetas[etiqueta] = etiquetas.get(etiqueta, 0) + int(cantidad)
        duplicados += int(chunk.text.duplicated().sum())
        longitudes.extend(chunk.text.fillna('').str.len().head(20_000))
    return total, etiquetas, duplicados, pd.Series(longitudes).describe()

n_total, etiquetas, duplicados_chunks, longitudes = auditoria_por_chunks()
print('Filas totales:', n_total)
print('Etiquetas:', etiquetas)
print('Duplicados dentro de chunks:', duplicados_chunks)
display(longitudes)
Filas totales: 1600000
Etiquetas: {0: 800000, 4: 800000}
Duplicados dentro de chunks: 9274
count    320000.000000
mean         73.953734
std          36.394516
min           6.000000
25%          44.000000
50%          69.000000
75%         103.000000
max         314.000000
dtype: float64

1. Limpieza de ruido

Primero retiramos o marcamos elementos que no son palabras convencionales: URLs, menciones, hashtags y puntuación. La limpieza es conservadora: una URL se convierte en URL, una mención en MENTION y un hashtag conserva su palabra. Así podemos medir después si esos elementos contienen señal.

Código
URL = re.compile(r'https?://\S+|www\.\S+', re.I)
MENTION = re.compile(r'@\w+')
HASHTAG = re.compile(r'#(\w+)')
NONWORD = re.compile(r'[^\w\s]', re.UNICODE)

def limpiar(texto, modo='conservador'):
    texto = URL.sub(' URL ', str(texto))
    texto = MENTION.sub(' MENTION ', texto)
    texto = HASHTAG.sub(r' HASHTAG_\1 ', texto)
    if modo == 'agresivo':
        texto = NONWORD.sub(' ', texto)
    return re.sub(r'\s+', ' ', texto).strip()

for texto in df.text.sample(5, random_state=SEED):
    print('ORIGINAL:', texto)
    print('LIMPIO:', limpiar(texto))
    print('AGRESIVO:', limpiar(texto, 'agresivo'), '\n')
ORIGINAL: whenever it rains it's so hard to get motivated 
LIMPIO: whenever it rains it's so hard to get motivated
AGRESIVO: whenever it rains it s so hard to get motivated 

ORIGINAL: @therealedjones lol shut uuuuuup ed NO its how I feel 
LIMPIO: MENTION lol shut uuuuuup ed NO its how I feel
AGRESIVO: MENTION lol shut uuuuuup ed NO its how I feel 

ORIGINAL: @mileycyrus hey miley  how are you  the climb is a really beautiful song well done on the vid its beautiful 
LIMPIO: MENTION hey miley how are you the climb is a really beautiful song well done on the vid its beautiful
AGRESIVO: MENTION hey miley how are you the climb is a really beautiful song well done on the vid its beautiful 

ORIGINAL: congrats. @ddlovato ill vote for u & tell all my friends to vote 2 
LIMPIO: congrats. MENTION ill vote for u & tell all my friends to vote 2
AGRESIVO: congrats MENTION ill vote for u amp tell all my friends to vote 2 

ORIGINAL: @KCtotheMAXXX: Contortionist kitteh! 
LIMPIO: MENTION : Contortionist kitteh!
AGRESIVO: MENTION Contortionist kitteh 

La limpieza agresiva reduce el vocabulario, pero puede borrar emoticones, negaciones o formas expresivas. En tweets, eliminar ruido y conservar señal son objetivos que deben equilibrarse.

2. Normalización

Normalizamos después de limpiar: pasamos a minúsculas y unificamos espacios. Mantener esta etapa separada permite ver exactamente qué cambios son de formato y cuáles son decisiones lingüísticas posteriores.

Código
def normalizar(texto):
    texto = str(texto).lower()
    texto = re.sub(r'\s+', ' ', texto).strip()
    return texto

ejemplo = "I do NOT like this movie!!! #Boring"
print('Limpieza:', limpiar(ejemplo))
print('Normalización:', normalizar(limpiar(ejemplo)))
Limpieza: I do NOT like this movie!!! HASHTAG_Boring
Normalización: i do not like this movie!!! hashtag_boring

La normalización reduce variantes accidentales como Good, GOOD y good, pero no resuelve por sí sola la morfología, las negaciones ni el contexto.

3. Tokenización

Convertimos cada tweet en unidades de análisis. La expresión regular conserva palabras, números, underscores de tokens especiales y contracciones como don't. Tokenizar no es simplemente separar por espacios: también hay que decidir qué ocurre con URLs, hashtags y signos.

Código
TOKEN_RE = re.compile(r"[A-Za-z0-9_]+(?:'[A-Za-z]+)?")

def tokenizar(texto):
    return TOKEN_RE.findall(normalizar(limpiar(texto)))

for texto in [ejemplo, "I live in New York", "Email me at data@example.com"]:
    print(texto)
    print(tokenizar(texto))
I do NOT like this movie!!! #Boring
['i', 'do', 'not', 'like', 'this', 'movie', 'hashtag_boring']
I live in New York
['i', 'live', 'in', 'new', 'york']
Email me at data@example.com
['email', 'me', 'at', 'data', 'mention', 'com']

La tokenización define las unidades que llegarán a stopwords, stemming, lematización y vectorización. Un tokenizador distinto puede producir un vocabulario y un modelo distintos.

4. Identificación del corpus como inglés

No necesitamos detectar el idioma tweet por tweet para esta demostración: la documentación del corpus establece que Sentiment140 es un conjunto en inglés. Esa información determina qué lista de stopwords y qué recursos lingüísticos debemos usar. En una colección multilingüe, esta decisión tendría que hacerse por documento.

Código
IDIOMA_CORPUS = 'en'
print('Idioma declarado por la fuente:', IDIOMA_CORPUS)
print('Recursos lingüísticos seleccionados: stopwords y SnowballStemmer en inglés')
Idioma declarado por la fuente: en
Recursos lingüísticos seleccionados: stopwords y SnowballStemmer en inglés

5. Stopwords en inglés

Las stopwords son palabras muy frecuentes que suelen aportar poca información temática. Las retiramos después de tokenizar, no antes. Conservamos una lista de respaldo para que la demostración funcione aunque NLTK no tenga descargado su recurso de stopwords.

Código
stop_en = {'the', 'a', 'an', 'and', 'or', 'is', 'are', 'was', 'were', 'to', 'of', 'in', 'for', 'on', 'at', 'it', 'this', 'that', 'with', 'my', 'your', 'i', 'you', 'we', 'they'}
if stopwords is not None:
    try:
        stop_en = set(stopwords.words('english'))
    except Exception as exc:
        print(f'NLTK stopwords no disponible; se usa la lista de respaldo ({type(exc).__name__}).')

print('Número de stopwords:', len(stop_en))
print('Ejemplo:', sorted(stop_en)[:20])
Número de stopwords: 25
Ejemplo: ['a', 'an', 'and', 'are', 'at', 'for', 'i', 'in', 'is', 'it', 'my', 'of', 'on', 'or', 'that', 'the', 'they', 'this', 'to', 'was']

La lista de stopwords depende del idioma y del objetivo. En sentimientos no conviene borrar automáticamente palabras gramaticales que puedan modificar la polaridad.

6. Conservación explícita de negaciones

no, not, never, nothing, neither y nor se excluyen de la eliminación estándar. Si desaparecen, frases como I like it y I do not like it pueden quedar representadas de manera demasiado parecida.

Código
NEGACIONES = {'no', 'not', 'never', 'nothing', 'neither', 'nor', "n't"}
stop_sentimiento = stop_en - NEGACIONES

frases = ['I like this movie', 'I do not like this movie', 'I never like sequels']
for frase in frases:
    tokens = tokenizar(frase)
    sin_negaciones = [t for t in tokens if t not in stop_en]
    con_negaciones = [t for t in tokens if t not in stop_sentimiento]
    print(frase)
    print('Stopwords estándar:', sin_negaciones)
    print('Negaciones conservadas:', con_negaciones, '\n')
I like this movie
Stopwords estándar: ['like', 'movie']
Negaciones conservadas: ['like', 'movie'] 

I do not like this movie
Stopwords estándar: ['do', 'not', 'like', 'movie']
Negaciones conservadas: ['do', 'not', 'like', 'movie'] 

I never like sequels
Stopwords estándar: ['never', 'like', 'sequels']
Negaciones conservadas: ['never', 'like', 'sequels'] 

7. Stemming

El stemming aplica reglas rápidas para recortar terminaciones. Puede unir formas relacionadas, pero también producir raíces que no son palabras y fusionar términos que no deberían compartir representación.

Código
stemmer = SnowballStemmer('english') if SnowballStemmer else None
familia = ['love', 'loved', 'loving', 'loves', 'movies', 'happier']
for palabra in familia:
    salida = stemmer.stem(palabra) if stemmer else palabra
    print(f'{palabra:10} -> {salida}')
love       -> love
loved      -> loved
loving     -> loving
loves      -> loves
movies     -> movies
happier    -> happier

Stemming es rápido y útil para reducir vocabulario, pero su resultado es puramente morfológico. No entiende el significado de la raíz ni el contexto en el que aparece.

8. Lematización

La lematización intenta devolver una palabra a su forma de diccionario. WordNet se usa cuando sus recursos están disponibles; si no, aplicamos una reducción conservadora y dejamos constancia de ello.

Código
try:
    lematizador = WordNetLemmatizer() if WordNetLemmatizer else None
    lematizador.lemmatize('tweets')
    wordnet_ok = True
except (LookupError, AttributeError):
    lematizador = None
    wordnet_ok = False

def lematizar_token(token):
    if wordnet_ok:
        return lematizador.lemmatize(token)
    reglas = {'tweets': 'tweet', 'loved': 'love', 'likes': 'like', 'liked': 'like', 'running': 'run'}
    return reglas.get(token, token)

for palabra in ['tweets', 'loved', 'likes', 'running', 'better']:
    print(f'{palabra:10} -> {lematizar_token(palabra)}')
print('WordNet disponible:', wordnet_ok)
tweets     -> tweet
loved      -> love
likes      -> like
running    -> run
better     -> better
WordNet disponible: False

La lematización suele ser más interpretable que el stemming, aunque puede ser más lenta y depende de recursos lingüísticos. La elección depende del objetivo y del costo aceptable.

9. Construcción de n-gramas

Los unigramas representan palabras aisladas. Los bigramas y trigramas conservan pequeñas ventanas de contexto, lo que ayuda a representar expresiones como not good que no se entienden bien observando solo good.

Código
def ngramas(tokens, n=2):
    return ['_'.join(tokens[i:i+n]) for i in range(len(tokens)-n+1)]

tokens_ejemplo = ['i', 'do', 'not', 'like', 'this', 'movie']
print('Unigramas:', tokens_ejemplo)
print('Bigramas:', ngramas(tokens_ejemplo, 2))
print('Trigramas:', ngramas(tokens_ejemplo, 3))
Unigramas: ['i', 'do', 'not', 'like', 'this', 'movie']
Bigramas: ['i_do', 'do_not', 'not_like', 'like_this', 'this_movie']
Trigramas: ['i_do_not', 'do_not_like', 'not_like_this', 'like_this_movie']

Los n-gramas aumentan el vocabulario y el costo de memoria. Su beneficio debe compararse con el riesgo de sobreajuste, especialmente en muestras pequeñas.

10. Pipeline completo usado para producir text_clean

Juntamos las etapas anteriores en una función única. Esta función es la transformación que se aplica al corpus antes de entrenar: limpieza, normalización, tokenización, stopwords, conservación de negaciones y lematización.

Código
def pipeline_texto(texto, reduccion='lema', conservar_negaciones=True):
    tokens = tokenizar(texto)
    stop = stop_sentimiento if conservar_negaciones else stop_en
    tokens = [t for t in tokens if t not in stop and len(t) > 1]
    if reduccion == 'stem' and stemmer:
        tokens = [stemmer.stem(t) for t in tokens]
    elif reduccion == 'lema':
        tokens = [lematizar_token(t) for t in tokens]
    return tokens

pipeline_ejemplo = "I do not like this movie!!! #boring https://example.com"
print('Original:', pipeline_ejemplo)
print('Tokens finales:', pipeline_texto(pipeline_ejemplo))
print('Tokens con stemming:', pipeline_texto(pipeline_ejemplo, reduccion='stem'))
print('Sin conservar negaciones:', pipeline_texto(pipeline_ejemplo, conservar_negaciones=False))

df['label'] = df.target.map({0: 'negativo', 4: 'positivo'})
df['text_clean'] = df.text.map(lambda t: ' '.join(pipeline_texto(t, reduccion='lema')))
df['n_words'] = df.text.fillna('').str.split().str.len()
df = df[df.label.notna() & df.text_clean.str.len().gt(0)].copy()
display(df[['text', 'text_clean', 'label']].head(10))
Original: I do not like this movie!!! #boring https://example.com
Tokens finales: ['do', 'not', 'like', 'movie', 'hashtag_boring', 'url']
Tokens con stemming: ['do', 'not', 'like', 'movie', 'hashtag_boring', 'url']
Sin conservar negaciones: ['do', 'not', 'like', 'movie', 'hashtag_boring', 'url']
text text_clean label
0 @chrishasboobs AHHH I HOPE YOUR OK!!! mention ahhh hope ok negativo
1 @misstoriblack cool , i have no tweet apps  fo... mention cool have no tweet apps razr negativo
2 @TiannaChaos i know  just family drama. its la... mention know just family drama its lame hey ne... negativo
3 School email won't open  and I have geography ... school email won't open have geography stuff t... negativo
4 upper airways problem upper airways problem negativo
5 Going to miss Pastor's sermon on Faith... going miss pastor's sermon faith negativo
6 on lunch....dj should come eat with me lunch dj should come eat me positivo
7 @piginthepoke oh why are you feeling like that? mention oh why feeling like negativo
8 gahh noo!peyton needs to live!this is horrible gahh noo peyton needs live horrible negativo
9 @mrstessyman thank you glad you like it! There... mention thank glad like there product review b... positivo

text_clean ya no es una limpieza superficial: es la salida reproducible de todo el pipeline lingüístico. Por eso el modelo y la tabla de predicciones se pueden rastrear hasta las decisiones de preprocesamiento.

11. TF-IDF y modelos

Separamos los datos antes de ajustar cualquier representación. Todas las combinaciones usan la misma partición estratificada. Las variables text_stem, text_lemma, train y test quedan disponibles para todas las secciones siguientes.

Bag of Words cuenta ocurrencias; TF-IDF pondera términos frecuentes en un documento pero menos informativos en el corpus. Cada modelo se entrena en su propia subsección para que el efecto de cada decisión sea visible.

Código
df['text_stem'] = df.text.map(lambda t: ' '.join(pipeline_texto(t, reduccion='stem')))
df['text_lemma'] = df.text.map(lambda t: ' '.join(pipeline_texto(t, reduccion='lema')))
train, test = train_test_split(df, test_size=.2, stratify=df.label, random_state=SEED)

columnas = {'Stemming': 'text_stem', 'Lematización': 'text_lemma'}
representaciones = {
    'Bag of Words': lambda: TfidfVectorizer(use_idf=False, ngram_range=(1, 2), min_df=3, max_features=200_000),
    'TF-IDF': lambda: TfidfVectorizer(use_idf=True, ngram_range=(1, 2), min_df=3, max_features=200_000),
}
resultados = []
modelos_entrenados = {}
predicciones = {}

def evaluar_combinacion(nombre_modelo, constructor_modelo, nombre_prep, nombre_repr):
    columna = columnas[nombre_prep]
    clave = f'{nombre_modelo} · {nombre_prep} · {nombre_repr}'
    modelo = Pipeline([('vectorizador', representaciones[nombre_repr]()),
                       ('clasificador', constructor_modelo())])
    modelo.fit(train[columna], train.label)
    pred = modelo.predict(test[columna])
    precision, recall, _, _ = precision_recall_fscore_support(
        test.label, pred, average='binary', pos_label='positivo', zero_division=0)
    fila = {'modelo': nombre_modelo, 'preprocesamiento': nombre_prep,
            'representacion': nombre_repr, 'accuracy': accuracy_score(test.label, pred),
            'macro_f1': f1_score(test.label, pred, average='macro'),
            'precision_positivo': precision, 'recall_positivo': recall}
    resultados.append(fila)
    modelos_entrenados[clave] = modelo
    predicciones[clave] = pred
    print(f"{clave}: macro-F1={fila['macro_f1']:.3f} | accuracy={fila['accuracy']:.3f}")
    return modelo, pred

baseline = DummyClassifier(strategy='most_frequent').fit(train.text_lemma, train.label)
pred_baseline = baseline.predict(test.text_lemma)

Regresión Logística

La regresión logística es un clasificador lineal probabilístico. Funciona bien con matrices dispersas y permite interpretar los términos asociados a cada clase.

Regresión Logística · Stemming · Bag of Words

Código
modelo_lr_stem_bow, pred_lr_stem_bow = evaluar_combinacion('LogisticRegression', lambda: LogisticRegression(max_iter=100, n_jobs=-1, class_weight='balanced'), 'Stemming', 'Bag of Words')
LogisticRegression · Stemming · Bag of Words: macro-F1=0.790 | accuracy=0.790

Regresión Logística · Stemming · TF-IDF

Código
modelo_lr_stem_tfidf, pred_lr_stem_tfidf = evaluar_combinacion('LogisticRegression', lambda: LogisticRegression(max_iter=100, n_jobs=-1, class_weight='balanced'), 'Stemming', 'TF-IDF')
LogisticRegression · Stemming · TF-IDF: macro-F1=0.792 | accuracy=0.792

Regresión Logística · Lematización · Bag of Words

Código
modelo_lr_lemma_bow, pred_lr_lemma_bow = evaluar_combinacion('LogisticRegression', lambda: LogisticRegression(max_iter=100, n_jobs=-1, class_weight='balanced'), 'Lematización', 'Bag of Words')
LogisticRegression · Lematización · Bag of Words: macro-F1=0.791 | accuracy=0.791

Regresión Logística · Lematización · TF-IDF

Código
modelo_lr_lemma_tfidf, pred_lr_lemma_tfidf = evaluar_combinacion('LogisticRegression', lambda: LogisticRegression(max_iter=100, n_jobs=-1, class_weight='balanced'), 'Lematización', 'TF-IDF')
LogisticRegression · Lematización · TF-IDF: macro-F1=0.792 | accuracy=0.792

LinearSVC

LinearSVC aprende un margen de separación. Suele ser competitivo en clasificación de texto de alta dimensionalidad; su confianza se interpreta con la distancia al hiperplano.

LinearSVC · Stemming · Bag of Words

Código
modelo_svc_stem_bow, pred_svc_stem_bow = evaluar_combinacion('LinearSVC', lambda: LinearSVC(class_weight='balanced'), 'Stemming', 'Bag of Words')
LinearSVC · Stemming · Bag of Words: macro-F1=0.787 | accuracy=0.787

LinearSVC · Stemming · TF-IDF

Código
modelo_svc_stem_tfidf, pred_svc_stem_tfidf = evaluar_combinacion('LinearSVC', lambda: LinearSVC(class_weight='balanced'), 'Stemming', 'TF-IDF')
LinearSVC · Stemming · TF-IDF: macro-F1=0.778 | accuracy=0.778

LinearSVC · Lematización · Bag of Words

Código
modelo_svc_lemma_bow, pred_svc_lemma_bow = evaluar_combinacion('LinearSVC', lambda: LinearSVC(class_weight='balanced'), 'Lematización', 'Bag of Words')
LinearSVC · Lematización · Bag of Words: macro-F1=0.787 | accuracy=0.787

LinearSVC · Lematización · TF-IDF

Código
modelo_svc_lemma_tfidf, pred_svc_lemma_tfidf = evaluar_combinacion('LinearSVC', lambda: LinearSVC(class_weight='balanced'), 'Lematización', 'TF-IDF')
LinearSVC · Lematización · TF-IDF: macro-F1=0.779 | accuracy=0.779

ComplementNB

ComplementNB es una variante de Naive Bayes diseñada para ser más estable ante desbalance. Trabaja naturalmente con características no negativas como las producidas por Bag of Words y TF-IDF.

ComplementNB · Stemming · Bag of Words

Código
modelo_nb_stem_bow, pred_nb_stem_bow = evaluar_combinacion('ComplementNB', lambda: ComplementNB(), 'Stemming', 'Bag of Words')
ComplementNB · Stemming · Bag of Words: macro-F1=0.784 | accuracy=0.784

ComplementNB · Stemming · TF-IDF

Código
modelo_nb_stem_tfidf, pred_nb_stem_tfidf = evaluar_combinacion('ComplementNB', lambda: ComplementNB(), 'Stemming', 'TF-IDF')
ComplementNB · Stemming · TF-IDF: macro-F1=0.780 | accuracy=0.780

ComplementNB · Lematización · Bag of Words

Código
modelo_nb_lemma_bow, pred_nb_lemma_bow = evaluar_combinacion('ComplementNB', lambda: ComplementNB(), 'Lematización', 'Bag of Words')
ComplementNB · Lematización · Bag of Words: macro-F1=0.785 | accuracy=0.785

ComplementNB · Lematización · TF-IDF

Código
modelo_nb_lemma_tfidf, pred_nb_lemma_tfidf = evaluar_combinacion('ComplementNB', lambda: ComplementNB(), 'Lematización', 'TF-IDF')
ComplementNB · Lematización · TF-IDF: macro-F1=0.780 | accuracy=0.780

Cada combinación recibe la misma partición y queda guardada en resultados, modelos_entrenados y predicciones. Así podemos comparar sin ocultar el proceso dentro de un único ciclo.

12. Evaluación temporal robusta

La partición aleatoria mezcla fechas. Para aproximarnos a un caso de producción, entrenamos con tweets antiguos y evaluamos sobre el 20% final de las fechas. El parser reemplaza abreviaturas de zona horaria problemáticas y comprueba que ambos subconjuntos tengan datos antes de entrenar.

Código
def parsear_fechas(serie):
    parsed = pd.to_datetime(serie, errors='coerce', utc=True)
    if parsed.notna().mean() < 0.5:
        normalizada = serie.astype(str).str.replace(r'\s[A-Z]{3}\s', ' UTC ', regex=True)
        parsed = pd.to_datetime(normalizada, errors='coerce', utc=True)
    return parsed

df['date_parsed'] = parsear_fechas(df.date)
print(f"Fechas interpretadas: {df.date_parsed.notna().mean():.1%}")
temporal = df.dropna(subset=['date_parsed']).sort_values('date_parsed')
if temporal.date_parsed.nunique() < 2:
    raise ValueError('No hay suficientes fechas interpretables para la validación temporal.')
corte = temporal.date_parsed.quantile(.8)
tr_t = temporal[temporal.date_parsed < corte]
te_t = temporal[temporal.date_parsed >= corte]
if tr_t.empty or te_t.empty:
    raise ValueError(f'La partición temporal quedó vacía: train={len(tr_t)}, test={len(te_t)}')

modelo_temporal = Pipeline([('tfidf', TfidfVectorizer(ngram_range=(1, 2), min_df=3, max_features=200_000)),
                            ('clf', LogisticRegression(max_iter=100, class_weight='balanced'))])
modelo_temporal.fit(tr_t.text_clean, tr_t.label)
pred_t = modelo_temporal.predict(te_t.text_clean)
print('Corte temporal:', corte)
print(classification_report(te_t.label, pred_t, digits=3))
Fechas interpretadas: 100.0%
Corte temporal: 2009-06-15 23:24:05.800000+00:00
              precision    recall  f1-score   support

    negativo      0.975     0.770     0.861     18407
    positivo      0.226     0.775     0.350      1593

    accuracy                          0.770     20000
   macro avg      0.601     0.773     0.605     20000
weighted avg      0.916     0.770     0.820     20000

La métrica temporal puede ser menor porque el modelo enfrenta vocabulario, temas y usuarios posteriores. Es una estimación más realista para predicción futura que una partición puramente aleatoria, aunque no elimina el ruido de las etiquetas.

13. Resultados finales, selección y prueba en vivo

Esta sección reúne los valores de las 12 combinaciones, grafica sus diferencias y selecciona el modelo ganador con criterios explícitos.

Código
comparacion = pd.DataFrame(resultados).sort_values(['macro_f1', 'accuracy'], ascending=False).reset_index(drop=True)
display(comparacion.style.format({c: '{:.3f}' for c in ['accuracy', 'macro_f1', 'precision_positivo', 'recall_positivo']}))

fig, axes = plt.subplots(1, 3, figsize=(18, 5), sharey=True)
for ax, nombre_modelo in zip(axes, ['LogisticRegression', 'LinearSVC', 'ComplementNB']):
    parte = comparacion[comparacion.modelo == nombre_modelo].copy()
    parte['combinacion'] = parte.preprocesamiento + ' + ' + parte.representacion
    ax.barh(parte.combinacion, parte.macro_f1, color=['#4c78a8', '#f58518', '#54a24b', '#e45756'])
    ax.set_title(nombre_modelo)
    ax.set_xlabel('Macro-F1')
    ax.set_xlim(0, 1)
    ax.grid(axis='x', alpha=.25)
plt.suptitle('Comparación dentro de cada modelo')
plt.tight_layout()
plt.show()

plt.figure(figsize=(12, 7))
etiquetas = comparacion.modelo + ' | ' + comparacion.preprocesamiento + ' | ' + comparacion.representacion
plt.barh(etiquetas[::-1], comparacion.macro_f1[::-1], color=plt.cm.viridis(np.linspace(.1, .9, len(comparacion))))
plt.xlabel('Macro-F1')
plt.title('Todas las combinaciones ordenadas por Macro-F1')
plt.xlim(0, 1)
plt.grid(axis='x', alpha=.25)
plt.tight_layout()
plt.show()
  modelo preprocesamiento representacion accuracy macro_f1 precision_positivo recall_positivo
0 LogisticRegression Lematización TF-IDF 0.792 0.792 0.790 0.797
1 LogisticRegression Stemming TF-IDF 0.792 0.792 0.791 0.795
2 LogisticRegression Lematización Bag of Words 0.791 0.791 0.786 0.799
3 LogisticRegression Stemming Bag of Words 0.790 0.790 0.786 0.799
4 LinearSVC Lematización Bag of Words 0.787 0.787 0.783 0.794
5 LinearSVC Stemming Bag of Words 0.787 0.787 0.783 0.794
6 ComplementNB Lematización Bag of Words 0.785 0.785 0.793 0.771
7 ComplementNB Stemming Bag of Words 0.784 0.784 0.793 0.770
8 ComplementNB Lematización TF-IDF 0.780 0.780 0.788 0.767
9 ComplementNB Stemming TF-IDF 0.780 0.780 0.788 0.767
10 LinearSVC Lematización TF-IDF 0.779 0.779 0.777 0.783
11 LinearSVC Stemming TF-IDF 0.778 0.778 0.776 0.783

Criterios para seleccionar la combinación final

Usamos macro-F1 como métrica principal porque da el mismo peso a negativo y positivo. En caso de empate práctico, usamos accuracy como segundo criterio y preferimos la alternativa más interpretable. La selección no significa que el ganador sea universal: solo es el mejor bajo esta partición, muestra, preprocesamiento y conjunto de etiquetas.

Código
mejor = comparacion.iloc[0]
clave_mejor = f"{mejor['modelo']} · {mejor['preprocesamiento']} · {mejor['representacion']}"
mejor_modelo = modelos_entrenados[clave_mejor]
pred_mejor = predicciones[clave_mejor]
columna_mejor = columnas[mejor['preprocesamiento']]
print('Combinación seleccionada:', clave_mejor)
print(f"Criterio: mayor macro-F1 ({mejor['macro_f1']:.3f}); desempate por accuracy ({mejor['accuracy']:.3f}).")
print(classification_report(test.label, pred_mejor, digits=3))
Combinación seleccionada: LogisticRegression · Lematización · TF-IDF
Criterio: mayor macro-F1 (0.792); desempate por accuracy (0.792).
              precision    recall  f1-score   support

    negativo      0.794     0.788     0.791      9989
    positivo      0.790     0.797     0.793     10011

    accuracy                          0.792     20000
   macro avg      0.792     0.792     0.792     20000
weighted avg      0.792     0.792     0.792     20000

Ejemplos de comentarios clasificados en vivo

La función siguiente permite reemplazar la lista de ejemplos por comentarios propios. Aplica exactamente el preprocesamiento elegido y devuelve la predicción del modelo ganador.

Código
def clasificar_comentarios(comentarios):
    procesados = [' '.join(pipeline_texto(t, reduccion='stem' if mejor['preprocesamiento'] == 'Stemming' else 'lema')) for t in comentarios]
    pred = mejor_modelo.predict(procesados)
    salida = pd.DataFrame({'comentario': comentarios, 'prediccion': pred})
    if hasattr(mejor_modelo, 'predict_proba'):
        salida['confianza'] = mejor_modelo.predict_proba(procesados).max(axis=1).round(3)
    else:
        scores = mejor_modelo.decision_function(procesados)
        salida['confianza'] = (1 / (1 + np.exp(-np.abs(scores)))).round(3)
    return salida

comentarios_demo = [
    'I absolutely loved this, the best experience ever!',
    'The service was terrible and I will never come back.',
    'It was okay, nothing special but not bad.',
    'Great, another update broke the whole application.',
]
display(clasificar_comentarios(comentarios_demo))

# Para probar tus propios ejemplos, reemplaza la lista anterior:
# comentarios_propios = ['Write a comment here', 'Write another comment here']
# display(clasificar_comentarios(comentarios_propios))
comentario prediccion confianza
0 I absolutely loved this, the best experience e... positivo 0.937
1 The service was terrible and I will never come... negativo 0.885
2 It was okay, nothing special but not bad. negativo 0.724
3 Great, another update broke the whole applicat... negativo 0.653

Conclusión

La conclusión debe describir qué combinación ganó, cuánto superó al baseline, si stemming o lematización produjo una diferencia consistente y si Bag of Words o TF-IDF fue más útil dentro de cada modelo. También debe comparar la métrica aleatoria con la temporal y recordar que Sentiment140 usa etiquetas ruidosas generadas por emoticones.