feat(eda): capítulo text_distr (TEXTO/NLP) — primer capítulo de datos no tabulares
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>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
"""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": []}
|
||||
Reference in New Issue
Block a user