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>
122 lines
3.8 KiB
Python
122 lines
3.8 KiB
Python
"""Legibilidad Flesch Reading Ease de un corpus de texto.
|
|
|
|
Función pura del grupo `eda`, estilo dict-no-throw: nunca lanza. Usa la
|
|
librería `textstat` con import perezoso y degradación: si `textstat` no está
|
|
instalada (o falla al importar), devuelve un resultado con `available=False`
|
|
en lugar de propagar el error.
|
|
"""
|
|
|
|
|
|
def _percentile_nearest_rank(sorted_values, pct):
|
|
"""Percentil por nearest-rank sobre una lista ya ordenada ascendente.
|
|
|
|
rank = ceil(pct/100 * n); índice 1-based recortado a [1, n].
|
|
Devuelve None si la lista está vacía.
|
|
"""
|
|
n = len(sorted_values)
|
|
if n == 0:
|
|
return None
|
|
import math
|
|
|
|
rank = math.ceil((pct / 100.0) * n)
|
|
if rank < 1:
|
|
rank = 1
|
|
if rank > n:
|
|
rank = n
|
|
return sorted_values[rank - 1]
|
|
|
|
|
|
def compute_text_readability(texts, sample_max=500) -> dict:
|
|
"""Calcula la legibilidad Flesch Reading Ease de un corpus.
|
|
|
|
Args:
|
|
texts: lista de str. Los elementos None, no-str o vacíos (tras strip)
|
|
se descartan. Se muestrean los primeros `sample_max` documentos
|
|
válidos.
|
|
sample_max: número máximo de documentos a puntuar (los primeros).
|
|
|
|
Returns:
|
|
Dict con la forma exacta::
|
|
|
|
{"available": bool, "n_scored": int,
|
|
"flesch": {"mean": float|None, "p50": float|None,
|
|
"min": float|None, "max": float|None}}
|
|
|
|
`available` es True si `textstat` se pudo importar. La función nunca
|
|
lanza: cualquier excepción global degrada a `available=False`.
|
|
"""
|
|
empty = {
|
|
"available": False,
|
|
"n_scored": 0,
|
|
"flesch": {"mean": None, "p50": None, "min": None, "max": None},
|
|
}
|
|
try:
|
|
# Import perezoso con degradación: textstat es una dependencia opcional.
|
|
try:
|
|
import textstat
|
|
except Exception:
|
|
return {
|
|
"available": False,
|
|
"n_scored": 0,
|
|
"flesch": {"mean": None, "p50": None, "min": None, "max": None},
|
|
}
|
|
|
|
# Filtrar y muestrear documentos válidos (los primeros sample_max).
|
|
docs = []
|
|
if texts is not None:
|
|
try:
|
|
limit = int(sample_max)
|
|
except Exception:
|
|
limit = 500
|
|
if limit < 0:
|
|
limit = 0
|
|
for item in texts:
|
|
if not isinstance(item, str):
|
|
continue
|
|
if item.strip() == "":
|
|
continue
|
|
docs.append(item)
|
|
if len(docs) >= limit:
|
|
break
|
|
|
|
scores = []
|
|
for doc in docs:
|
|
try:
|
|
score = textstat.flesch_reading_ease(doc)
|
|
except Exception:
|
|
continue
|
|
try:
|
|
score = float(score)
|
|
except Exception:
|
|
continue
|
|
scores.append(score)
|
|
|
|
n_scored = len(scores)
|
|
if n_scored == 0:
|
|
# textstat presente pero corpus vacío / sin puntuar.
|
|
return {
|
|
"available": True,
|
|
"n_scored": 0,
|
|
"flesch": {"mean": None, "p50": None, "min": None, "max": None},
|
|
}
|
|
|
|
mean_val = round(sum(scores) / n_scored, 1)
|
|
sorted_scores = sorted(scores)
|
|
p50_raw = _percentile_nearest_rank(sorted_scores, 50)
|
|
p50_val = round(p50_raw, 1) if p50_raw is not None else None
|
|
min_val = sorted_scores[0]
|
|
max_val = sorted_scores[-1]
|
|
|
|
return {
|
|
"available": True,
|
|
"n_scored": n_scored,
|
|
"flesch": {
|
|
"mean": mean_val,
|
|
"p50": p50_val,
|
|
"min": min_val,
|
|
"max": max_val,
|
|
},
|
|
}
|
|
except Exception:
|
|
return empty
|