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.
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.
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 textoejemplo ="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 isnotNone:try: stop_en =set(stopwords.words('english'))exceptExceptionas 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])
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 - NEGACIONESfrases = ['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 notin stop_en] con_negaciones = [t for t in tokens if t notin 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 elseNonefamilia = ['love', 'loved', 'loving', 'loves', 'movies', 'happier']for palabra in familia: salida = stemmer.stem(palabra) if stemmer else palabraprint(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.
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 inrange(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))
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 notin stop andlen(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 tokenspipeline_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.
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.
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
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
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.
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 inzip(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.representacionplt.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))
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})ifhasattr(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 salidacomentarios_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.