# The ZeroGPU hardware of the Space wants its package imported before spaCy try: import spaces except ImportError: spaces = None import re import threading from collections import Counter from collections.abc import Callable from functools import lru_cache from importlib.metadata import version from io import BytesIO from math import isfinite import gradio as gr import matplotlib import matplotlib.pyplot as plt import pandas as pd import spacy from PIL import Image from spacy.tokens import Doc from ests import ( BasicStats, CohesionStats, DiversityStats, LexicalStats, MorphStats, PhonStats, ReadabilityStats, StyleStats, SyntaxStats, VerseStats, WordsExtractor, ) from ests.constants import ( COHESION_STATS_DESC, HIGHLIGHT_DEFAULT_LAYERS, HIGHLIGHT_LAYER_GROUPS, HIGHLIGHT_LAYERS_DESC, LEXICAL_STATS_DESC, MORPHOLOGY_MARKERS_DESC, MORPHOLOGY_STATS_DESC, READABILITY_STATS_DESC, STYLE_STATS_DESC, SYNTAX_STATS_DESC, VERSE_STATS_DESC, ) from ests.corpus import collocations, keyness from ests.datasets import FreqDict from ests.exceptions import SourceError from ests.style_stats import is_stopword from ests.utils import get_nlp from ests.visualizers import highlight, sentence_lengths_plot, zipf matplotlib.use("Agg") MAX_CHARS = 20_000 MAX_MARK_RUN = 10 MARK_RUN = re.compile(rf"(?:[^\w\s]|_){{{MAX_MARK_RUN + 1},}}") ZIPF_WORDS = 100 SENTENCES_MIN = 5 SENTENCES_WINDOW = 5 ZIPF_MIN_WORDS = 100 KEYWORDS_TOP_N = 15 COLLOCATION_WINDOW = 3 VERSE_MAX_LINES = 40 VERSE_PATTERN_WIDTH = 24 VERSE_NOTE = ( "The stresses follow the rules of Spanish and are fitted to the meter; the pattern of a line " "is `-` for an unstressed syllable and `+` for a stressed one, the letter is its rhyme group, " "a hyphen an unrhymed line. Paste a poem line by line: a single line or a short phrase has " "no meter." ) KEYWORDS_NOTE = ( "Keywords are the words noticeably more frequent in the text than in the frequency " "dictionary of Google Books Ngram, as lemmas without the stopwords (G² - significance, " "Log Ratio - the binary logarithm of how many times more frequent). Collocations are pairs " "of content lemmas within three words of each other met at least twice, by logDice; a short " "text may have none." ) POS_NAMES = MORPHOLOGY_STATS_DESC["pos"]["values"] EXAMPLES = { "Tale": ( "Había una vez un campesino que tenía una gallina muy especial. Cada mañana la gallina " "ponía un huevo de oro. El campesino vendía los huevos en el mercado del pueblo y poco " "a poco se hizo rico. Pero un día pensó: «Si esta gallina pone huevos de oro, dentro " "debe de tener un tesoro». Tomó un cuchillo, mató a la gallina y la abrió. Dentro no " "había nada. Así perdió el campesino su gallina y todos sus huevos de oro por querer " "tenerlo todo de una vez." ), "Sonnet": ( "Cuando me paro a contemplar mi estado\n" "y a ver los pasos por do me han traído,\n" "hallo, según por do anduve perdido,\n" "que a mayor mal pudiera haber llegado;\n\n" "mas cuando del camino estó olvidado,\n" "a tanto mal no sé por dó he venido;\n" "sé que me acabo, y más he yo sentido\n" "ver acabar comigo mi cuidado.\n\n" "Yo acabaré, que me entregué sin arte\n" "a quien sabrá perderme y acabarme\n" "si quisiere, y aún sabrá querello;\n\n" "que pues mi voluntad puede matarme,\n" "la suya, que no es tanto de mi parte,\n" "pudiendo, ¿qué hará sino hacello?" ), "News": ( "El ayuntamiento aprobó ayer el programa de reforma de los patios interiores para el " "próximo año. Según el documento, en primer lugar se repararán los accesos y los parques " "infantiles de los barrios construidos antes de 1980, y las solicitudes que los vecinos " "presenten a través de la sede electrónica se estudiarán en el plazo de un mes. El gasto " "se calcula en doscientos millones de euros, y las empresas se elegirán por concurso " "público en marzo.\n\n" "El programa durará tres años. El primer año se arreglarán cuarenta patios, el segundo " "sesenta y el tercero todos los demás. Los vecinos podrán seguir las obras en un mapa " "que aparecerá en la web municipal en primavera, con las direcciones, los plazos y las " "empresas. Según explicó el servicio de prensa, para elegir los patios se tuvieron en " "cuenta la antigüedad de los edificios, el estado del pavimento y el número de quejas. " "Los concejales apoyaron el programa por unanimidad, pero pidieron que se informe de su " "cumplimiento cada trimestre y no una vez al año, como proponía el ayuntamiento." ), "Science": ( "Los tesauros son una clase especial de recursos lexicográficos que se caracterizan por " "los siguientes rasgos: la completitud de los significados del vocabulario de una lengua " "o de alguno de sus segmentos; la ordenación temática, o ideográfica, de los significados " "de las palabras. La diferencia entre los tesauros y las ontologías formales consiste en " "la salida hacia la esfera de los significados léxicos, en el establecimiento de " "relaciones no solo entre los significados y las palabras que los expresan, sino también " "entre los propios significados (el registro de diversas relaciones semánticas dentro " "del diccionario).\n\n" "La construcción de un tesauro supone delimitar los conceptos de un dominio, establecer " "entre ellos relaciones de sinonimia, hiponimia y asociación y asignar a cada concepto un " "conjunto de entradas textuales, es decir, las palabras y las expresiones con las que se " "expresa en los textos. La estructura resultante se emplea para ampliar las consultas en " "la búsqueda de información, clasificar documentos de forma automática y resolver la " "ambigüedad léxica, ya que permite relacionar formulaciones distintas con un mismo " "significado." ), "Officialese": ( "El proyecto, elaborado durante el verano, fue aprobado por el consejo sin debate. " "El aumento de la eficiencia del uso de los recursos públicos se analizó, siguiendo " "las normas del reglamento. Los representantes de los ministerios regionales no " "consiguieron hacer una revisión conjunta de las cuestiones de financiación y de reparto " "de responsabilidades entre los organismos, puesto que cada uno de ellos defendía su " "propia interpretación de las disposiciones del acuerdo. En el marco de la reunión se " "procedió a la votación, y la decisión quedó aplazada hasta la próxima sesión.\n\n" "Como resultado de la reunión, se adoptó la decisión de llevar a cabo consultas " "adicionales con la participación de los representantes de los organismos interesados " "a efectos de la elaboración de una posición común sobre la cuestión del reparto de " "competencias. La responsabilidad de la organización de las consultas y de la " "preparación del acta correspondiente fue atribuida a la secretaría, que deberá " "garantizar la consideración de las observaciones recibidas en el transcurso del debate." ), } DEFAULT_LAYERS = list(HIGHLIGHT_DEFAULT_LAYERS) DEFAULT_GROUP_LAYERS = [ [layer for layer in group if layer in HIGHLIGHT_DEFAULT_LAYERS] for group in HIGHLIGHT_LAYER_GROUPS.values() ] nlp = get_nlp() freq_dict = FreqDict() if not freq_dict.filepath: try: freq_dict.download() except (RuntimeError, OSError) as error: print(f"The frequency dictionary is not available: {error}") plot_lock = threading.Lock() if spaces is not None: @spaces.GPU def gpu_placeholder() -> None: """ZeroGPU starts a Space only with a GPU function; the demo computes on the CPU""" @lru_cache(maxsize=32) def parse(text: str) -> Doc: return nlp(text, disable=["ner"]) def format_value(value: float | int | str | None) -> str: if value is None: return "-" if isinstance(value, float): return f"{value:.2f}" if isfinite(value) else "-" return str(value) def stats_table( stats: dict[str, float], desc: dict[str, str], describe: Callable[[str], str | None] | None = None, ) -> pd.DataFrame: keys = [key for key in desc if key in stats] if describe is None: rows = [(desc[key], format_value(stats[key])) for key in keys] return pd.DataFrame(rows, columns=["Metric", "Value"]) rows_read = [(desc[key], format_value(stats[key]), describe(key) or "-") for key in keys] return pd.DataFrame(rows_read, columns=["Metric", "Value", "Reading"]) def format_reading_time(minutes: float) -> str: if minutes < 1: return "less than a minute" total = round(minutes) if total < 60: return f"{total} min" return f"{total // 60} h {total % 60} min" def readability_summary(rs: ReadabilityStats) -> str: grade = rs.describe_grade() return ( f"### {grade[:1].upper()}{grade[1:]}\n\n" f"Consensus grade - **{rs.consensus_grade:.1f}**, " f"Flesch reading ease by Szigriszt-Pazos - **{rs.flesch_reading_easy:.0f}** " f"({rs.describe('flesch_reading_easy')} on the INFLESZ scale), " f"reading time - **{format_reading_time(rs.reading_time)}**." ) def lexical_table(ls: LexicalStats) -> pd.DataFrame: if freq_dict.filepath: return stats_table(ls.get_stats(), LEXICAL_STATS_DESC) bands = { key: getattr(ls, key) for key in LEXICAL_STATS_DESC if key.startswith(("p_top", "p_beyond")) } bands["lexical_density"] = ls.lexical_density return stats_table(bands, LEXICAL_STATS_DESC) def keywords_table(forms: tuple[str, ...]) -> pd.DataFrame: columns = [ "Lemma", "In the text", "ipm in the text", "ipm in the dictionary", "G²", "Log Ratio", ] if not freq_dict.filepath or not forms: return pd.DataFrame(columns=columns) try: found = keyness(forms, freq_dict, min_freq=2) except SourceError: # No word of the text is made of the letters of the dictionary return pd.DataFrame(columns=columns) keywords = [keyword for keyword in found if not is_stopword(keyword.word)][:KEYWORDS_TOP_N] return pd.DataFrame( [ ( keyword.word, keyword.freq_target, round(keyword.ipm_target), round(keyword.ipm_reference, 1), round(keyword.g2, 1), round(keyword.log_ratio, 2), ) for keyword in keywords ], columns=columns, ) def collocations_table(lemmas: tuple[str, ...]) -> pd.DataFrame: found = [ collocation for collocation in collocations(lemmas, window=COLLOCATION_WINDOW, min_freq=2) if not is_stopword(collocation.left) and not is_stopword(collocation.right) ] return pd.DataFrame( [ ( f"{collocation.left} … {collocation.right}", collocation.freq_pair, round(collocation.score, 2), ) for collocation in found[:KEYWORDS_TOP_N] ], columns=["Pair", "Together", "logDice"], ) def verse_tables(doc: Doc) -> tuple[pd.DataFrame, str]: vs = VerseStats(doc) if not vs.n_lines: return stats_table(vs.get_stats(), VERSE_STATS_DESC), "*The text has no Spanish words.*" accented = [line for stanza in vs.accentuate().split("\n\n") for line in stanza.split("\n")] letters = [letter for scheme in vs.rhyme_schemes for letter in scheme] rows = [] for pattern, letter, line in zip(vs.patterns, letters, accented, strict=True): if len(pattern) > VERSE_PATTERN_WIDTH: pattern = pattern[: VERSE_PATTERN_WIDTH - 1] + "…" rows.append(f"{pattern:<{VERSE_PATTERN_WIDTH}} {letter} {line}") if len(rows) > VERSE_MAX_LINES: rows = [*rows[:VERSE_MAX_LINES], f"… {len(rows) - VERSE_MAX_LINES} more lines"] return stats_table(vs.get_stats(), VERSE_STATS_DESC), "```\n" + "\n".join(rows) + "\n```" def morph_tables(ms: MorphStats) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: stats = ms.get_stats(filter_none=True) pos = sorted(stats["pos"].items(), key=lambda item: -item[1]) pos_table = pd.DataFrame( [(POS_NAMES.get(tag, tag), count) for tag, count in pos], columns=["Part of speech", "Words"], ) rows = [] for category, counts in stats.items(): if category == "pos": continue desc = MORPHOLOGY_STATS_DESC[category] for value, count in sorted(counts.items(), key=lambda item: -item[1]): rows.append((desc["name"], desc["values"].get(value, value), count)) features = pd.DataFrame(rows, columns=["Feature", "Value", "Words"]) return pos_table, features, stats_table(ms.get_markers(), MORPHOLOGY_MARKERS_DESC) def figure_image(fig: plt.Figure) -> Image.Image: buffer = BytesIO() fig.tight_layout() fig.savefig(buffer, format="png", dpi=150) plt.close(fig) buffer.seek(0) return Image.open(buffer) def zipf_image(lemmas: tuple[str, ...]) -> Image.Image: counter = Counter(lemma for lemma in lemmas if not is_stopword(lemma)) with plot_lock: fig, ax = plt.subplots(figsize=(7, 4.5)) zipf( counter, num_words=min(ZIPF_WORDS, len(counter)), num_labels=6, show_theory=True, alpha=1.1, show_fit=True, ax=ax, ) return figure_image(fig) def sentences_image(doc: Doc) -> Image.Image: with plot_lock: fig, ax = plt.subplots(figsize=(7, 3.5)) sentence_lengths_plot(doc, window=SENTENCES_WINDOW, ax=ax) return figure_image(fig) def render_highlight(text: str, layers: list[str]) -> str: return highlight(parse(text), layers=layers).to_html() def compute(text: str, layers: list[str]) -> dict: # A long run of marks makes the tokenizer of spaCy quadratic text = MARK_RUN.sub(lambda match: match[0][:MAX_MARK_RUN], text.strip()[:MAX_CHARS]) if not text: raise ValueError("Paste a text to analyze") doc = parse(text) bs = BasicStats(doc) rs = ReadabilityStats(doc) ds = DiversityStats(doc) ms = MorphStats(doc) ss = StyleStats(doc) ps = PhonStats(doc) xs = SyntaxStats(doc) cs = CohesionStats(doc) ls = LexicalStats(doc, freq_dict=freq_dict) forms = WordsExtractor(filter_nums=True).extract(text) lemmas = WordsExtractor(use_lexemes=True, lowercase=True, filter_nums=True).extract(text) pos_table, morph_table, markers_table = morph_tables(ms) verse_table, verse_lines = verse_tables(doc) basic = {key: value for key, value in bs.get_stats().items() if key in BasicStats.stats_desc} enough_words = bs.n_words >= ZIPF_MIN_WORDS enough_sents = bs.n_sents >= SENTENCES_MIN return { "text": text, "highlight": highlight(doc, layers=layers).to_html(), "summary": readability_summary(rs), "readability": stats_table(rs.get_stats(), READABILITY_STATS_DESC, rs.describe), "diversity": stats_table(ds.get_stats(), DiversityStats.stats_desc), "pos": pos_table, "morph": morph_table, "markers": markers_table, "syntax": stats_table(xs.get_stats(), SYNTAX_STATS_DESC), "cohesion": stats_table(cs.get_stats(), COHESION_STATS_DESC), "lexical": lexical_table(ls), "style": stats_table(ss.get_stats(), STYLE_STATS_DESC), "phon": stats_table(ps.get_stats(), PhonStats.stats_desc), "verse": verse_table, "verse_lines": verse_lines, "basic": stats_table(basic, BasicStats.stats_desc), "keywords": keywords_table(forms), "collocations": collocations_table(lemmas), "zipf": zipf_image(lemmas) if enough_words else None, "enough_words": enough_words, "sentences": sentences_image(doc) if enough_sents else None, "enough_sents": enough_sents, } def analyze(text: str, *groups: list[str]): try: result = compute(text, [layer for group in groups for layer in group]) except ValueError as error: raise gr.Error(str(error)) from error return ( result["text"], result["highlight"], result["summary"], result["readability"], result["diversity"], result["pos"], result["morph"], result["markers"], result["syntax"], result["cohesion"], result["lexical"], result["style"], result["phon"], result["verse"], result["verse_lines"], result["basic"], result["keywords"], result["collocations"], gr.Image(value=result["zipf"], visible=result["enough_words"]), gr.Markdown(visible=not result["enough_words"]), gr.Image(value=result["sentences"], visible=result["enough_sents"]), ) def rerender(text: str | None, *groups: list[str]) -> str: if not text: return "" return render_highlight(text, [layer for group in groups for layer in group]) HEADER = """ # esTS - statistics of Spanish texts Paste a text in Spanish and get its readability with the school stage and the age of the reader, lexical diversity, the morphological and syntactic profile, cohesion, word frequency, the SEO metrics of style, phonostatistics, the meter and rhyme of verse, keywords and collocations, and the highlighting of the fragments these numbers come from. [GitHub](https://github.com/SergeyShk/esTS) · [Documentation](https://sergeyshk.github.io/esTS/) · [PyPI](https://pypi.org/project/pyests/) """ FOOTER = ( f"esTS {version('pyests')} · anyTS {version('anyts')} · spaCy {spacy.__version__} · " f"es_core_news_sm {nlp.meta['version']}" ) CSS = """ .ests-highlight { font-size: 1.05em; } .stats { --font-mono: var(--font); } #text-input, #text-input > label { display: flex; flex-direction: column; } #text-input > label, #text-input .input-container { flex: 1 1 auto; } #text-input .input-container { align-items: stretch; } """ INITIAL = compute(EXAMPLES["News"], DEFAULT_LAYERS) with gr.Blocks(title="esTS") as demo: gr.Markdown(HEADER) text_state = gr.State(INITIAL["text"]) highlight_output = gr.HTML( INITIAL["highlight"], label="Highlighting", container=True, padding=True, render=False ) summary_output = gr.Markdown(INITIAL["summary"], render=False) tables = { name: gr.Dataframe(INITIAL[name], interactive=False, elem_classes="stats", render=False) for name in ( "readability", "diversity", "morph", "markers", "syntax", "cohesion", "lexical", "style", "phon", "verse", "basic", "keywords", "collocations", ) } verse_lines_output = gr.Markdown(INITIAL["verse_lines"], render=False) pos_output = gr.BarPlot( INITIAL["pos"], x="Part of speech", y="Words", sort="-y", x_label_angle=-30, height=300, container=False, render=False, ) zipf_output = gr.Image( INITIAL["zipf"], label="Zipf's law", show_label=False, interactive=False, visible=INITIAL["enough_words"], render=False, ) zipf_note = gr.Markdown( f"*The plot of Zipf's law is drawn for texts of {ZIPF_MIN_WORDS} words and more.*", visible=not INITIAL["enough_words"], render=False, ) sentences_output = gr.Image( INITIAL["sentences"], label="Sentence lengths", show_label=False, interactive=False, visible=INITIAL["enough_sents"], render=False, ) outputs = [ text_state, highlight_output, summary_output, tables["readability"], tables["diversity"], pos_output, tables["morph"], tables["markers"], tables["syntax"], tables["cohesion"], tables["lexical"], tables["style"], tables["phon"], tables["verse"], verse_lines_output, tables["basic"], tables["keywords"], tables["collocations"], zipf_output, zipf_note, sentences_output, ] with gr.Row(equal_height=True): with gr.Column(scale=3): text_input = gr.Textbox( INITIAL["text"], lines=9, max_lines=9, max_length=MAX_CHARS, label="Text", elem_id="text-input", placeholder=f"Paste a text in Spanish, up to {MAX_CHARS:,} characters", ) with gr.Column(scale=1): layer_inputs = [ gr.CheckboxGroup( choices=[(HIGHLIGHT_LAYERS_DESC[layer], layer) for layer in group], value=[layer for layer in group if layer in HIGHLIGHT_DEFAULT_LAYERS], label=f"Highlighting: {name.lower()}", ) for name, group in HIGHLIGHT_LAYER_GROUPS.items() ] analyze_button = gr.Button("Analyze", variant="primary") gr.Examples( examples=[[text, *DEFAULT_GROUP_LAYERS] for text in EXAMPLES.values()], example_labels=list(EXAMPLES), inputs=[text_input, *layer_inputs], outputs=outputs, fn=analyze, run_on_click=True, cache_examples=False, label="Examples", ) with gr.Row(): with gr.Column(): highlight_output.render() zipf_output.render() zipf_note.render() sentences_output.render() with gr.Column(): summary_output.render() with gr.Tabs(): with gr.Tab("Readability"): tables["readability"].render() with gr.Tab("Diversity"): tables["diversity"].render() with gr.Tab("Morphology"): pos_output.render() tables["morph"].render() tables["markers"].render() with gr.Tab("Syntax"): tables["syntax"].render() with gr.Tab("Cohesion"): tables["cohesion"].render() with gr.Tab("Frequency"): tables["lexical"].render() with gr.Tab("Style"): tables["style"].render() with gr.Tab("Phonics"): tables["phon"].render() with gr.Tab("Verse"): gr.Markdown(VERSE_NOTE) tables["verse"].render() verse_lines_output.render() with gr.Tab("Basic"): tables["basic"].render() with gr.Tab("Keywords"): gr.Markdown(KEYWORDS_NOTE) tables["keywords"].render() tables["collocations"].render() gr.Markdown(FOOTER) analyze_button.click(analyze, inputs=[text_input, *layer_inputs], outputs=outputs) text_input.submit(analyze, inputs=[text_input, *layer_inputs], outputs=outputs) for layer_input in layer_inputs: layer_input.change( rerender, inputs=[text_state, *layer_inputs], outputs=[highlight_output] ) if __name__ == "__main__": demo.launch(css=CSS)