105e56cf05
Añade el capítulo `text_distr` al motor AutomaticEDA: perfila columnas de texto libre largo (reseñas, descripciones, comentarios) que la distribución categórica no resume bien. Sigue el patrón de cat_distr/num_distr (build_text_distr(profile, ctx) -> Chapter | None) y se registra en CHAPTER_ORDER tras cat_distr. Activación en dos fases: gate barato desde el perfil (columna no numérica con len_mean >= 50 chars) + confirmación con muestra cruda (mediana de palabras >= 20). Un dataset sin texto largo (p.ej. titanic) devuelve None sin tocar el informe. Bloques por columna (Group con page_break): resumen (longitudes, vocabulario con TTR y % hapax, idioma dominante, % duplicados, legibilidad), histograma de longitudes, top términos (tabla + barras), bigramas/trigramas, idiomas detectados y nube de palabras opcional. Términos ttr/hapax enganchados al glosario clicable. Lógica delegada a 7 funciones nuevas del registry (datascience, tag eda), estilo dict-no-throw: - extract_text_sample (impura, push-down SQL DuckDB/Postgres) - compute_text_length_stats, compute_vocabulary_stats, compute_top_ngrams (puras, stdlib) - detect_corpus_language (langdetect opcional), compute_text_readability (textstat opcional), compute_text_duplicates (hash + datasketch opcional) Versión barata sin modelos pesados: las piezas que dependen de una librería opcional (langdetect, textstat, wordcloud, datasketch) degradan a omitidas sin lanzar. Añade langdetect y textstat (ligeras) al pyproject + uv.lock. Verificado: golden sobre dataset de reviews multi-idioma (capítulo presente en PDF+PPTX+MD con métricas reales), titanic sin capítulo (None), degradación sin libs, suite automatic_eda + pipeline verde (128 passed), fn index OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
95 lines
4.0 KiB
Python
95 lines
4.0 KiB
Python
"""Top n-gramas de palabras más frecuentes de un corpus de texto.
|
|
|
|
Función pura, autocontenida (solo stdlib: re + collections.Counter). No depende
|
|
de scikit-learn ni de ninguna otra librería externa. Estilo dict-no-throw del
|
|
grupo `eda`: ante cualquier entrada degenerada o excepción interna devuelve
|
|
``{"n": n, "top": []}`` en vez de lanzar.
|
|
"""
|
|
|
|
import re
|
|
from collections import Counter
|
|
|
|
# Lista inline de stopwords ES + EN (~80 términos de altísima frecuencia).
|
|
# Se eliminan ANTES de formar los n-gramas: los n-gramas se construyen sobre la
|
|
# secuencia de tokens de contenido, no sobre el texto original.
|
|
_STOPWORDS = frozenset({
|
|
# Español
|
|
"de", "la", "que", "el", "en", "y", "a", "los", "del", "se", "las", "por",
|
|
"un", "para", "con", "no", "una", "su", "al", "lo", "como", "más", "mas",
|
|
"pero", "sus", "le", "ya", "o", "este", "sí", "si", "porque", "esta",
|
|
"entre", "cuando", "muy", "sin", "sobre", "también", "tambien", "me",
|
|
"hasta", "hay", "donde", "quien", "desde", "todo", "nos", "durante",
|
|
"todos", "uno", "les", "ni", "contra", "otros", "ese", "eso", "ante",
|
|
"ellos", "e", "esto", "mí", "antes", "algunos", "qué", "unos", "yo",
|
|
"otro", "otras", "otra", "él", "tanto", "esa", "estos", "mucho", "quienes",
|
|
"nada", "muchos", "cual", "poco", "ella", "estar", "estas", "algunas",
|
|
"algo", "nosotros",
|
|
# Inglés
|
|
"the", "of", "and", "to", "in", "is", "it", "for", "on", "with", "as",
|
|
"are", "was", "be", "this", "that", "by", "an", "or", "at", "from", "but",
|
|
"not", "have", "has", "had", "they", "you", "we", "he", "she", "his",
|
|
"her", "their", "its", "i", "my", "me", "our", "us", "do", "does", "did",
|
|
"will", "would", "can", "could", "should", "there", "which", "who", "what",
|
|
"when", "where", "how", "all", "if", "so", "than", "then", "out", "up",
|
|
})
|
|
|
|
|
|
def compute_top_ngrams(texts, n=2, top_k=15, remove_stopwords=True) -> dict:
|
|
"""Calcula los n-gramas de palabras más frecuentes de un corpus.
|
|
|
|
Args:
|
|
texts: lista de cadenas. Los elementos ``None`` o que no sean ``str`` se
|
|
descartan silenciosamente.
|
|
n: tamaño del n-grama (1 = unigramas, 2 = bigramas, 3 = trigramas...).
|
|
Valores < 1 o no enteros producen ``top`` vacío.
|
|
top_k: número máximo de n-gramas a devolver, ordenados por frecuencia
|
|
descendente (con desempate alfabético determinista).
|
|
remove_stopwords: si ``True`` elimina las stopwords ES+EN ANTES de
|
|
formar los n-gramas, de modo que los n-gramas se construyen sobre la
|
|
secuencia de tokens de contenido (no cruzando documentos).
|
|
|
|
Returns:
|
|
``{"n": n, "top": [{"ngram": "w1 w2", "count": int}, ...]}``. Corpus
|
|
vacío, sin tokens suficientes o cualquier excepción interna degrada a
|
|
``{"n": n, "top": []}``. Nunca lanza.
|
|
"""
|
|
try:
|
|
if not isinstance(n, int) or n < 1:
|
|
return {"n": n, "top": []}
|
|
|
|
try:
|
|
limit = int(top_k)
|
|
except (TypeError, ValueError):
|
|
limit = 0
|
|
if limit < 0:
|
|
limit = 0
|
|
|
|
if not isinstance(texts, (list, tuple)):
|
|
return {"n": n, "top": []}
|
|
|
|
counter = Counter()
|
|
for doc in texts:
|
|
if not isinstance(doc, str):
|
|
continue
|
|
tokens = [
|
|
tok
|
|
for tok in re.findall(r"\w+", doc.lower(), re.UNICODE)
|
|
if not tok.isdigit()
|
|
]
|
|
if remove_stopwords:
|
|
tokens = [tok for tok in tokens if tok not in _STOPWORDS]
|
|
if len(tokens) < n:
|
|
continue
|
|
for i in range(len(tokens) - n + 1):
|
|
ngram = " ".join(tokens[i:i + n])
|
|
counter[ngram] += 1
|
|
|
|
if not counter:
|
|
return {"n": n, "top": []}
|
|
|
|
ordered = sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
top = [{"ngram": ngram, "count": count} for ngram, count in ordered[:limit]]
|
|
return {"n": n, "top": top}
|
|
except Exception:
|
|
return {"n": n, "top": []}
|