Mostrando entradas con la etiqueta SpaCy. Mostrar todas las entradas
Mostrando entradas con la etiqueta SpaCy. Mostrar todas las entradas

miércoles, 10 de junio de 2026

DATOS. Tratamiento de datos

Datos no estructurados (VI)

Reconocimiento de entidades (NER) (III)

Visto el esquema de opciones disponibles para el acceso NER, describiremos en esta entrada la primera de estas opciones, identificada como enfoque heurístico por pipeline híbrido .

En el ámbito de la orientación educativa, garantizar la estricta confidencialidad de los datos y el escrupuloso manejo de la información debiene en un condicionante determinante de los modelos de gestión documental, incluyendo la automatización de los procedimientos de acceso a los datos que esos documentos contienen, incluso para funciones indirectamente relacionadas con las motivaciones que justifican el propio manejo de dichos datos, como son las relativas al análisis de la intervención. La promesa de una solución sencilla, accesible para los recursos de hartware disponibles y garantista en cuanto a la confidencialidad resulta tan prometedores como defrauda comprobar el bajo rendimiento real que se obtiene en su aplicación real. Un ejemplo de ello es el escaso éxito que obtuvimos en la obtención de NER en esta entrada sobre un texto sintético, pero ajustado a un modelo textual concreto: una reseña de reunión de seguimiento entre tutor/a y orientador/a.

En total, mediante el correspondiente script tratado en esa entrada, en el texto se identificaron 15 entidades, 7 correctamente, lo que supone el 47%, esto es, menos de la mitad de las identificadas; aunque lo que es más negativo es que el error (NER erróneas) alcanzó el 33% (una de cada tres) y se omitieron estructuras fundamentales para un posterior análisis de datos, como son las fechas de jecución de actuaciones. Este bajo nivel de éxito y las importantes limitaciones que se aprecian (por error o por omisión) nos llevan a considerar claramente insuficiente un procemimiento como el empleado y nos obliga a plantear mejoras en los procedimientos y/o a buscar alternativas, manteniéndonos, en todo caso, dentro de las condiciones reales de un proyecto de estas características en nuestro contexto: limitaciones de recursos (hardware) y escrupuloso cumplimiento de estrictas condiciones de confidencialidad.

En este nuestro marco, con sus limitaciones, choca con el planteamiento técnico predominante, en el que se asume que el procesamiento de lenguaje natural (PLN) exige el uso de grandes modelos probabilísticos o arquitecturas basadas en Transformers (como BERT). Sin embargo, en entornos, como el nuestro, en los que los recursos de hardware son limitados y la transferencia de datos a la nube es inviable, los modelos estadísticos puros podrían ser alternativas válidas. Pero, por desgracia hemos podido comprobar sus importantes limitaciones que se manifiestan como errores de precisión o confusiones de categorización. Por ello es necesario plantear alternativas como las que ofrece el enfoque heurístico basado en reglas y componentes secuenciales, método que demuestra su máxima viabilidad en corpus documentales regulados por una estructura de contenido y forma específica, como son las actas elaboradas a partir de un modelo, los documentos creados a partir de plantillas o los contenidos estructurados, que se incluyen en documentos complejos, como síntesis de informes de resultados de tests insertados en los informes psicpopedagógicos.

Para que entendamos mejor el significado de estas limitaciones de forma y contenido, podríamos pensar en un determinado documento que se produce con cierta frecuencia y que, por ese mismo motivo, a partir de un momento determinado se ha desarrollado un procedimiento de automatización, basado en OOo Basic, por ejemplo , en el que se solicita al orientador una serie de datos (mediante un formulario basado en Calc, o un conjunto de InputBix()), que se usan para personalizar el contenido de un texto formalizado. Si en es procedimiento de automatización se hubiera previsto la recogida sistemática de datos, al cabo de cierto tiempo dispondríamos de una base de datos (datos estructurados) que sería a la que recurriéramos para analizar estas actuaciones; pero de ser así, o de haberse perdido dicha base de datos, nos veríamos obligados a revisar todos los documentos creados para obtener manual o automáticamente, esos datos. Este es precisamente el papel del script que nos planteamos desarrollar aquí, ajustándolo a las peculiaridades del documento y a su contenido, empleando para ello lo que hemos identificado como enfoque heurístico.

Pero, ¿en qué consiste este enfoque?. Este enfoque propone implementar una tubería de procesamiento (o pipeline) dirigida por conocimiento experto (nuestro análisis del documento), previa al uso de la arquitectura de la librería de código abierto SpaCy. esto nos permite fragmentar el análisis del texto en fases sucesivas, estratégicas y deterministas, que se desarrollar completamente en la memoria local del equipo, lo que evita cualquier riesgo de vulneración del principio de confidencialidad.

La fases a las que hacemos mención son las siguientes:

[Texto] ─> 1. Blindaje (EntityRuler) ─> 2. NER estadístico ─> 3. Asignador de Roles ─> [JSON estructurado]

Lo específico del modelo que proponemos (otras fases están ya presentes en el modelo-base) se concreta aquí como fase de blindaje o aplicación del catálogo de patrones que hemos ido creando para que se ajusten a la realidad de los datos de nuestro texto, y que se implementa antes de que el modelo estadístico general (SpaCy) actue (fase 2), identificando las entidades.

Esta fase primera se basa en un componente de la biblioteca SpaCy llamado EntityRuler , compuesto por bases de datos locales y heurísticas sintácticas creadas en base a expresiones regulares (RegEx)

Para entender el desenvolvimiento de este modelo partiremos del punto de partida: la aplicación pura y dura de SpaCy, simplificando el script original al mínimo imprescindible :



# 0 Importamos la biblioteca SpaCy
import spacy

# 1. Cargamos el modelo en español
nlp = spacy.load("es_core_news_md")

# Aquí el texto a analizar

# 2. Procesamos el texto: SpaCy tokeniza, analiza la sintaxis y extrae entidades
doc = nlp(texto_expediente)

# Aquí mostramos las entidades por CMD


Sobre este simple estructura vamos a ir construyendo el llamado pipeline en respuesta a las necesidades detectadas, construcción que sigue su propia lógica y no la sucesión que observaremos en la formulación final del script, como tendremos ocasión de comprobar. Esto se debe a que el modo de proceder se basa en la identificación (por parte del experto) de las carencias que se aprecian en los resultados del script original y en los sucesivos intentos de solución. Esto, en nuestro caso, y sin que debe considerarse procedimiento canónico o estandard, se concreta como ausencia de detección de unos datos que se considerar (circunstancialmente) claves y que no están identificados como NER en el modelo español de la biblioteca: me refiero a las entidades FECHA, que vamos a implementar en el pipeline como primer componente.


# 0 Importamos la biblioteca SpaCy y el componente EntityRuler
import spacy
from spacy.pipeline import EntityRuler

# 1. Cargamos el modelo en español
nlp = spacy.load("es_core_news_md")

# 2. Añadimos el componente EntityRuler antes del componente NER estadístico
ruler = nlp.add_pipe("entity_ruler", before="ner")

# 3. Definimos la lista de patrones precisos para la nueva categoría "FECHA"
patterns = [
    # ---- FORMATO 1: Textual completo (Ej: "12 de mayo") o con año (Ej: "12 de mayo de 2026") ----
    {
        "label": "FECHA",
        "pattern": [
            {"IS_DIGIT": True},                               # El día (ej: "12")
            {"LOWER": "de"},                                  # Nexo
            {"LOWER": {"IN": ["enero", "febrero", "marzo", "abril", "mayo", "junio", 
                              "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"]}}, # El mes
            {"LOWER": "de", "OP": "?"},                       # Nexo opcional (por si no hay año)
            {"IS_DIGIT": True, "OP": "?"}                     # El año opcional (ej: "2026")
        ]
    },
    
    # ---- FORMATO 2: Mes y año (Ej: "mayo de 2026") ----
    {
        "label": "FECHA",
        "pattern": [
            {"LOWER": {"IN": ["enero", "febrero", "marzo", "abril", "mayo", "junio", 
                              "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"]}},
            {"LOWER": "de"},
            {"IS_DIGIT": True}
        ]
    },
    
    # ---- FORMATO 3: Numérico corto con barras o guiones mediante RegEx (Ej: "02/06/2026") ----
    {
        "label": "FECHA",
        "pattern": [
            {"TEXT": {"REGEX": r"^\d{1,2}[/-]\d{1,2}[/-]\d{2,4}$"}}
        ]
    }
]

# 4. Inyectamos las reglas deterministas en el EntityRuler
ruler.add_patterns(patterns)

# Aquí el texto a analizar

# 5. Procesamos el texto: SpaCy tokeniza, analiza la sintaxis y extrae entidades
doc = nlp(texto_expediente)

# Aquí mostramos las entidades por CMD


Este primer paso se incia con la incorporación del componente EntityRuler from spacy.pipeline import EntityRuler y lo añadimos tras cargar el modelo español de Spacy ruler = nlp.add_pipe("entity_ruler", before="ner"). LO que viene a continuación (y previo a la ejecución de SpaCy sobre el texto, es la implementación de los patrones de detección de formatos de fecha y su posterior inyección del pipeline en el EntyRuler ruler.add_patterns(patterns). Después se desarrolla el proceso ya conocido.

Lo que obtenemos con la inclusión de estos patrones y su ejecución es la detección de las fechas, las que se ajustan a los formatos previstos en la expresiones RegEx empleadas, pero no las fechas que no se ajustan a esos patrones. Esto se observa en nuestro caso porque el script resultante es capaz de identificar Texto: 12 de mayo como FECHA gracias al patron 1, pero ignora la palabra martes que antecede a la fecha identificada. (6).

El siguiente paso consiste en la correcta identificación de los centros escolares y las localidades, al haber observado que SpaCy no dispone de etiqueta específica, pero tampoco detecta con seguridad un centro escolar como ORG al predominar la identificación de la localidad (LOC) cuando la información se presenta unificada en una expresión única (ejemplo: CEIP San Juan Bautista de Oviedo). Muestro a continuación la implementación del código del pipeline que permite identificar centros y localidades, y que se añade al anterior (reglas de fechas)



# 0 Importamos la biblioteca SpaCy y el componente EntityRuler
import spacy
from spacy.pipeline import EntityRuler

# 1. Cargamos el modelo en español
nlp = spacy.load("es_core_news_md")

# 2. Añadimos el componente EntityRuler antes del componente NER estadístico
ruler = nlp.add_pipe("entity_ruler", before="ner")

# Aquí incluimos los nuevos elementos: centros y localidades------------------

# 3. Listas y reglas de centros y localidades

# 3.1 BASES DE DATOS LOCALES : Centros y Listas

# LISTA 1. Centros
centros_del_sector = [
    "CEIP San Juan Bautista",
    "IES Monte Naranco",
    "Colegio San Ignacio"
]

# LISTA 2. Localidades
localidades_sector = [
    "Oviedo",
    "Mieres",
    "Gijón",
    "Avilés",
    "La Felguera"
]

# 3.2 Construimos el catálogo de patrones
patterns = []

# A. Incorporamos el listado cerrado de centros
for centro in centros_del_sector:
    patterns.append({
        "label": "CENTRO_ESCOL",
        "pattern": centro
    })

# B. Incorporamos el listado cerrado de localidades
for localidad in localidades_sector:
    patterns.append({
        "label": "LOCALIDAD",
        "pattern": localidad
    })

# C. Procedimiento genérico para identificar otros centros (heurística)
patterns.append({
    "label": "CENTRO_ESCOL",
    "pattern": [
        {"LOWER": {"IN": ["ceip", "ies", "cra", "cp", "colegio", "instituto"]}},
        {"IS_TITLE": True, "OP": "+"} 
    ]
})


# Aquí mantenemos las reglas de identificación de las fechas------------------

# 4. Definimos la lista de patrones precisos para la nueva categoría "FECHA"

patterns = [
    # ---- FORMATO 1: Textual completo (Ej: "12 de mayo") o con año (Ej: "12 de mayo de 2026") ----
    {
        "label": "FECHA",
        "pattern": [
            {"IS_DIGIT": True},                               # El día (ej: "12")
            {"LOWER": "de"},                                  # Nexo
            {"LOWER": {"IN": ["enero", "febrero", "marzo", "abril", "mayo", "junio", 
                              "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"]}}, # El mes
            {"LOWER": "de", "OP": "?"},                       # Nexo opcional (por si no hay año)
            {"IS_DIGIT": True, "OP": "?"}                     # El año opcional (ej: "2026")
        ]
    },
    
    # ---- FORMATO 2: Mes y año (Ej: "mayo de 2026") ----
    {
        "label": "FECHA",
        "pattern": [
            {"LOWER": {"IN": ["enero", "febrero", "marzo", "abril", "mayo", "junio", 
                              "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"]}},
            {"LOWER": "de"},
            {"IS_DIGIT": True}
        ]
    },
    
    # ---- FORMATO 3: Numérico corto con barras o guiones mediante RegEx (Ej: "02/06/2026") ----
    {
        "label": "FECHA",
        "pattern": [
            {"TEXT": {"REGEX": r"^\d{1,2}[/-]\d{1,2}[/-]\d{2,4}$"}}
        ]
    }
]

# 5. Inyectamos las reglas deterministas en el EntityRuler
ruler.add_patterns(patterns)

# Aquí el texto a analizar

# 6. Procesamos el texto: SpaCy tokeniza, analiza la sintaxis y extrae entidades
doc = nlp(texto_expediente)

# Aquí mostramos las entidades por CMD


En este caso hemos optado por la segunda fórmula de construcción del componente EntityRuler: el uso de listas, una para los centros del sector y otra para las localidades. Al tratarse de listas, es posible crearlas directamente en el script (como es el caso), pudiendo modificarlas sobre el mismo código, pero también podríamos acceder a ellas como elementos externos complementarios soportados sobre un archivo tipo CSV, por ejemplo.

Obsérvese que, además, en el caso de los centros hemos implementado una segunda forma de detección, esta vez basada en reglas. Ambos procedimientos (lista y reglas) se plantean aquí como complementarios, la lista para los centros del sector, las reglas para el resto, pero también se pueden considerar redundantes. De hecho, en buena lógica, la regla debería ser suficiente, haciendo innecesaria la lista. En cualquier caso interesa que observemos ambos necesitan ser implementados en un catálogo de patrones previamente definido patterns = [] mediante append() (ej. patterns.append({"label": "CENTRO_ESCOL","pattern": centro})).

Aun quedan por resolver dos problemas detectados en el script inicial:

  • La primera es la incorrecta identificación de alguna entidad PER como LOC, debido a que algún elemento del nombre (en este caso el segundo apellido), puede identificarse también como localidad (en Don Roberto Pando, Pando se ajusta a esta casuística)
  • La segunda es de mayor complejidad y a la vez también más específica de la realidad profesional de los SEO: no es suficiente con identificar a las personas, es necesario diferenciando su rol personal/profesional, ya que no es lo mismo que María sea la madre de la niña, la propia niña o su profesora de PT, por ejemplo.

Aunque se trate de una única entidad (PER), y aunque esté correctamente contemplada en SpaCy y generalmente sea correctamente identificada, lo cierto es que se pueden producir errores como el observado, derivando en confusión de entidad y lo peor es que no es una entidad satisfactoriamente especificidada. Pero la forma de afrontar estas problemáticas es diferente: resolver la primera implica ajustar e implmentar una regla RegEx específica; mientras que resolver la segunda supone complementar las reglas primarias con reglas secundarias.

En consecuencia, la primera cuestión requiere algo así como esto (ver en los comentarios la explicación):



# ==============================================================================
# E. Reglas de IDENTIFICACIÓN VERAZ DE PERSONAS (Evita falsos LOC)
# ==============================================================================
patterns.extend([
    # Regla para: Tratamiento (Don/Doña/Sr/Sra) + Nombre + Apellido(s)
    # Captura: "Doña Carmen Villalobos", "Don Roberto Pando", "la Sra. Elena Soler"
    {
        "label": "PER",
        "pattern": [
            {"LOWER": {"IN": ["don", "doña", "sr.", "sra.", "sr", "sra", "dñ", "dña"]}},
            {"IS_TITLE": True},          # Primer Nombre (ej. Roberto)
            {"IS_TITLE": True, "OP": "+"} # Uno o más apellidos en mayúscula (ej. Pando)
        ]
    },
    # Regla de respaldo heurística por si aparece "el Sr. Apellido" o "la Sra. Apellido"
    {
        "label": "PER",
        "pattern": [
            {"LOWER": {"IN": ["sr.", "sra.", "sr", "sra"]}},
            {"IS_TITLE": True, "OP": "+"}
        ]
    }
])
# ==============================================================================


Mientras que lo segundo es más complejo...



# ==============================================================================
# EXTRACCIÓN SEMÁNTICA DE ROLES (Componente post-NER)
# ==============================================================================

# Registramos la extensión personalizada "rol" en las entidades (Span) de spaCy
Span.set_extension("rol", default="DESCONOCIDO", force=True)

@Language.component("asignador_de_roles")
def asignador_de_roles(doc):
    # Taxonomía jerárquica: de lo específico a lo genérico
    ROLES = {
        "ALUMNO": ["alumno", "alumna", "menor", "escolar", "discente"],
        "FAMILIAR": ["padre", "madre", "tutor", "tutora", "progenitor", "progenitores", "familiar"],
        "TUTOR_AULA": ["tutora,", "tutor,", "tutor/a", "tutoría"], # Comas contempladas por la apposición
        "PROF_PT": ["profesor pt", "profesora pt", "maestro pt", "maestra pt", "especialista pt", "pedagogía terapéutica", "pt"],
        "PROF_AL": ["profesor de al", "maestra de al", "especialista de al", "audición y lenguaje", "al"],
        "PROF_APOYO": ["profesor de apoyo", "profesora de apoyo", "maestra de apoyo", "apoyo educativo"],
        "SEO_MIEMBRO": ["orientador", "orientadora", "psc", "ptsc", "trabajador social", "trabajadora social", "profesor técnico", "profesora técnica", "equipo de orientación"],
        "DIRECTOR": ["director", "directora", "dirección", "jefa de estudios"],
        # La categoría genérica de área se evalúa al final
        "PROF_AREA": ["profesor de", "profesora de", "maestro de", "maestra de", "docente"]
    }
    
    for ent in doc.ents:
        # Solo buscamos roles para las entidades identificadas como personas
        if ent.label_ == "PER":
            # Ventana de contexto amplia: 7 tokens a la izquierda y 7 a la derecha
            inicio_ventana = max(0, ent.start - 7)
            fin_ventana = min(len(doc), ent.end + 7)
            contexto = doc[inicio_ventana:fin_ventana].text.lower()
            
            # Evaluación determinista del rol
            for rol, palabras_clave in ROLES.items():
                if any(palabra in contexto for palabra in palabras_clave):
                    ent._.rol = rol
                    break # Detiene la evaluación al hallar la máxima coincidencia
                    
    return doc

# Añadimos el componente al final de la tubería
nlp.add_pipe("asignador_de_roles", after="ner")

# ==============================================================================


... se ubica con posterioridad a la aplicación del NER en sentido estricto Span.set_extension("rol", default="DESCONOCIDO", force=True), requiere identificar los roles posibles mediante listas específicas def asignador_de_roles(doc):, recorrer las entidades localizadas en la fase previa por SpaCy for ent in doc.ents: de forma condicionada a la identificación del rol PER if ent.label_ == "PER":, para aplicar esa segunda catgorización especifica de las personas en función de su rol for rol, palabras_clave in ROLES.items(): y finalmente incorporarla (la categorización de PER) dentro del pipeline nlp.add_pipe("asignador_de_roles", after="ner").

El resultado de todo el proceso seguido hasta ahora es este script que, ahora sí, te ofrezco completo...



import spacy
from spacy.pipeline import EntityRuler
from spacy.language import Language
from spacy.tokens import Span

# 1. Cargamos el modelo base
nlp = spacy.load("es_core_news_md")

# ==============================================================================
# FASE A: BLINDAJE PREVIO (EntityRuler antes del NER estadístico)
# ==============================================================================
ruler = nlp.add_pipe("entity_ruler", before="ner")

# --- BASES DE DATOS LOCALES ---
centros_del_sector = [
    "CEIP San Juan Bautista",
    "IES Monte Naranco",
    "Colegio San Ignacio"
]

localidades_sector = [
    "Oviedo",
    "Mieres",
    "Gijón",
    "Avilés",
    "La Felguera"
]

# Construcción del catálogo de patrones
patterns = []

# A. Listado cerrado de centros del sector
for centro in centros_del_sector:
    patterns.append({"label": "CENTRO_ESCOL", "pattern": centro})

# B. Listado cerrado de localidades
for localidad in localidades_sector:
    patterns.append({"label": "LOCALIDAD", "pattern": localidad})

# C. Heurística genérica para identificar otros centros
patterns.append({
    "label": "CENTRO_ESCOL",
    "pattern": [
        {"LOWER": {"IN": ["ceip", "ies", "cra", "cp", "colegio", "instituto"]}},
        {"IS_TITLE": True, "OP": "+"} 
    ]
})

# D. Reglas de FECHAS
patterns.extend([
    {
        "label": "FECHA",
        "pattern": [
            {"IS_DIGIT": True},
            {"LOWER": "de"},
            {"LOWER": {"IN": ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"]}},
            {"LOWER": "de", "OP": "?"},
            {"IS_DIGIT": True, "OP": "?"}
        ]
    },
    {
        "label": "FECHA",
        "pattern": [{"TEXT": {"REGEX": r"^\d{1,2}[/-]\d{1,2}[/-]\d{2,4}$"}}]
    }
])

# E. Identificación de personas (Evita falsos LOC/falsos negativos)
patterns.extend([
    {
        "label": "PER",
        "pattern": [
            {"LOWER": {"IN": ["don", "doña", "sr.", "sra.", "sr", "sra", "dñ", "dña"]}},
            {"IS_TITLE": True},          # Nombre
            {"IS_TITLE": True, "OP": "+"} # Apellidos
        ]
    },
    {
        "label": "PER",
        "pattern": [
            {"LOWER": {"IN": ["sr.", "sra.", "sr", "sra"]}},
            {"IS_TITLE": True, "OP": "+"}
        ]
    }
])

ruler.add_patterns(patterns)


# ==============================================================================
# FASE B: EXTRACCIÓN SEMÁNTICA DE ROLES (Componente post-NER)
# ==============================================================================

# Registramos la extensión personalizada "rol" en las entidades (Span) de spaCy
Span.set_extension("rol", default="DESCONOCIDO", force=True)

@Language.component("asignador_de_roles")
def asignador_de_roles(doc):
    # Taxonomía jerárquica: de lo específico a lo genérico
    ROLES = {
        "ALUMNO": ["alumno", "alumna", "menor", "escolar", "discente"],
        "FAMILIAR": ["padre", "madre", "tutor", "tutora", "progenitor", "progenitores", "familiar"],
        "TUTOR_AULA": ["tutora,", "tutor,", "tutor/a", "tutoría"], # Comas contempladas por la apposición
        "PROF_PT": ["profesor pt", "profesora pt", "maestro pt", "maestra pt", "especialista pt", "pedagogía terapéutica", "pt"],
        "PROF_AL": ["profesor de al", "maestra de al", "especialista de al", "audición y lenguaje", "al"],
        "PROF_APOYO": ["profesor de apoyo", "profesora de apoyo", "maestra de apoyo", "apoyo educativo"],
        "SEO_MIEMBRO": ["orientador", "orientadora", "psc", "ptsc", "trabajador social", "trabajadora social", "profesor técnico", "profesora técnica", "equipo de orientación"],
        "DIRECTOR": ["director", "directora", "dirección", "jefa de estudios"],
        # La categoría genérica de área se evalúa al final
        "PROF_AREA": ["profesor de", "profesora de", "maestro de", "maestra de", "docente"]
    }
    
    for ent in doc.ents:
        # Solo buscamos roles para las entidades identificadas como personas
        if ent.label_ == "PER":
            # Ventana de contexto amplia: 7 tokens a la izquierda y 7 a la derecha
            inicio_ventana = max(0, ent.start - 7)
            fin_ventana = min(len(doc), ent.end + 7)
            contexto = doc[inicio_ventana:fin_ventana].text.lower()
            
            # Evaluación determinista del rol
            for rol, palabras_clave in ROLES.items():
                if any(palabra in contexto for palabra in palabras_clave):
                    ent._.rol = rol
                    break # Detiene la evaluación al hallar la máxima coincidencia
                    
    return doc

# Añadimos el componente al final de la tubería
nlp.add_pipe("asignador_de_roles", after="ner")


# ==============================================================================
# FASE C: EJECUCIÓN Y SALIDA ESTRUCTURADA
# ==============================================================================

texto_expediente = (
    "El alumno Alejandro Martínez Soler, escolarizado en 4º de Primaria del "
    "CEIP San Juan Bautista de Oviedo, presenta dificultades de adaptación significativas. "
    "La tutora, Doña Carmen Villalobos, informa de que tras la reunión mantenida "
    "el pasado martes 12 de mayo con la madre del menor, la Sra. Elena Soler, "
    "no se ha observado mejoría en el aula. El Equipo de Orientación Educativa (EOEP) "
    "ha sido notificado por el Director del centro, Don Roberto Pando, clasificando "
    "el expediente como intervención prioritaria. Se ha solicitado formalmente la "
    "colaboración del Servicio de Salud del Principado de Asturias (SESPA) para "
    "coordinar la valoración psiquiátrica del menor antes del viernes 19 de junio. "
    "Nota: Toda la documentación clínica de Alejandro ha sido archivada en el "
    "aplicativo institucional SAECE según el protocolo de la Consejería de Educación."
)

doc = nlp(texto_expediente)

print(f"{'TEXTO DETECTADO':<32} | {'CATEGORÍA':<15} | {'ROL ASIGNADO':<15}")
print("-" * 70)
for entidad in doc.ents:
    # Accedemos a la extensión personalizada usando el prefijo "._." obligatoriamente en spaCy
    rol_etiqueta = entidad._.rol if entidad.label_ == "PER" else "N/A"
    print(f"{entidad.text:<32} | {entidad.label_:<15} | {rol_etiqueta:<15}")


... gracias al cual, sobre el texto trabajado, obtenemos el siguiente resultado:

TEXTO DETECTADO | CATEGORÍA | ROL ASIGNADO
----------------------------------------------------------------------
Alejandro Martínez Soler | PER | ALUMNO
Primaria del | LOC | N/A
CEIP San Juan Bautista | CENTRO_ESCOL | N/A
Oviedo | LOCALIDAD | N/A
La tutora | MISC | N/A
Doña Carmen Villalobos | PER | FAMILIAR
12 de mayo | FECHA | N/A
Sra. Elena Soler | PER | ALUMNO
El Equipo de Orientación Educativa | MISC | N/A
EOEP | MISC | N/A
Director del centro | MISC | N/A
Don Roberto Pando | PER | DIRECTOR
Servicio de Salud del Principado de Asturias | ORG | N/A
SESPA | ORG | N/A
19 de junio | FECHA | N/A
Nota: Toda la documentación clínica | MISC | N/A
Alejandro | PER | PROF_AL
SAECE | MISC | N/A
Consejería de Educación | ORG | N/A

... en el que podemos apreciar una evolución positiva respecto a lo obtenido en aplicación del primer script, pero también errores.

  • El número de NER identificadas asciende de 15 a 19 (cerca de 27% de incremento).
  • El número de aciertos también asciende de forma significativa: inicialmente se situaba cerca de 47% (7/15) y ahora, a pesar del incremento de NER identificadas, ascience al 63% (12/19), lo que supone un incremento del 16,5%.
  • Pero, además de no resolverse todas las insuficiencias, especialmente las que se refieren a las entidades identificadas en la categorías MISC, que se mantienen invariables; el éxito en la identificación de roles de las personas es muy reducido, ya que no alcanza el 50% del total (2/5) y se observan errores importantes.

Es posible que algunos de estos errores se puedan corregir afinando las reglas RegEx a aplicar o implementando procedimientos complementarios, y de hecho ese es el camino que aun podríamos recorrer de seguir dentro de este modelo; pero incluso estas posibilidades acentuan las limitaciones del modelo heurístico, del que, por resumir, indicamos a continuación, y para terminar, ventajas e inconvenientes.

Como ventajas destacar...

  • Determinismo absoluto: A diferencia de los modelos probabilísticos, no sufre de alucinaciones ni variabilidad. Ante el mismo texto, produce exactamente el mismo resultado.
  • Eficiencia extrema: Mantiene un consumo de RAM plano e ínfimo (frecuentemente inferior a 150 MB), ejecutándose localmente en milisegundos.
  • Mantenimiento ágil y soberano: Si el sistema comete un error, no se requiere un complejo reentrenamiento del modelo; basta con ajustar un patrón o un término en el diccionario local.

Y como limitaciones y restricciones...

  • Dependencia absoluta de la homogeneidad formal: El sistema es estructuralmente rígido. Si los profesionales modifican significativamente la forma en que se expresan, la tasa de fallos se dispara bruscamente.
  • Fragilidad ante la ambigüedad sintáctica: Al depender de ventanas de contexto fijas (por ejemplo, buscar el rol 7 tokens antes o después del nombre), cualquier digresión larga, inciso extenso o subordinada compleja romperá la asociación semántica, impidiemdo la correcta identificación de la entidad.
  • Techo de escalabilidad y fatiga de reglas: Aunque el mantenimiento inicial es ágil, el procedimiento tiene un límite de crecimiento. Si el universo documental se expande con demasiadas excepciones, las reglas e inclusiones terminan colisionando entre sí, generando falsos positivos cruzados que requieren un esfuerzo de depuración humana difícil de mantener a la larga.

Lo cierto, no obstante, es que en un proyecto real y de interés para el SEO, aun tendríamos un buen margen de mejora; pero es mejor aun saber que disponemos de otras alternativas, aunque también presenten sus propias limitaciones.

Nota
1 Para la elaboración de esta entrada se han consultado diversas fuentes, incluyendo consultaestructurada a Gemini.
2Véase, por ejemplo, el DocAp simple basado en texto mutilado que se puede consultar en esta entrada.
3El EntityRuler es un componente de la popular biblioteca de PLN en Python, spaCy permite identificar, clasificar y extraer información textual basándose estrictamente en un conjunto de instrucciones, reglas o diccionarios predefinidos por el usuario, en lugar de depender de la inferencia estadística de un modelo. Su función es la extracción de entidades nombradas (NER) basada en reglas (Rule-based Named Entity Recognition). Mientras que un modelo de aprendizaje automático adivina qué es una entidad en el texto, el EntityRuler busca coincidencias exactas y etiquetas personalizadas que tú le has indicado previamente.Puedes utilizarlo de las siguientes maneras: (a) Creación de entidades personalizadas: Te permite definir tus propios patrones y asignarles etiquetas (como PRODUCT, PERSON, ORG, etc.) y (b) Combinación con modelos estadísticos: Se puede añadir al pipeline de un modelo existente (como en_core_web_sm) para mejorar la precisión y corregir clasificaciones erróneas.
4 Regex, Regular Expressions o expresiones regulares, es una secuencia de caracteres que forma un patrón de búsqueda que se utiliza en programación, en análisis de datos y en editores de texto para buscar, validar, extraer o reemplazar información específica dentro de grandes bloques de texto de manera rápida y flexible.
5Luego no es un script funcional, como no lo van a ser los que le sigan hasta que se plantee el definitivo, que se indicará específicamente.
6 Este "error" se mantiene conscientemente por motivos didácticos, precisamente para ilustrar las limitaciones de los patrones y la necesidad de especificarlos de forma que se ajusten a la realidad sobre la que se supone que deben actuar.

sábado, 6 de junio de 2026

DATOS. Tratamiento de datos

Datos no estructurados (IV)

Reconocimiento de entidades (NER) (I)

Para seguir avanzando en el aprendizaje de cómo tratar los datos no estructurados, antes de pasar a la matematización del contenido de los textos, debemos retomar los procedimientos ejecutados hasta ahora pero con un texto y una problemática diferente a la del empleado en esos procedimientos.

Debo decir que ese texto (evidentemente inventado) sirvió perfectamente para el objetivo, pero dejó voluntariamente aparcada una problemática que resulta fundamental resolver dentro del ámbito de trabajo de los SEO: el manejo de datos confidenciales. Quedó también sin desarrollar un procedimiento que tiene también gran interés para el manejo de textos, y creo que muy especialmente para nosotros: la identificación temática del contenido o, mejor dicho, el rconocimiento de entidades (NER). Además resulta que ambas temáticas están relacionas, así que me propongo tratarlas en esta entrada y en otras que siguan, antes de avanzar en el grueso del procedimiento.

Para ello, como dije, es necesario emplear otro documento como texto de referencia. Al leerlo se entenderá el motivo.

El alumno Alejandro Martínez Soler, escolarizado en 4º de Primaria del CEIP San Juan Bautista de Oviedo, presenta dificultades de adaptación significati_vas. La tutora, Doña Carmen Villalobos, informa de que tras la reunión mantenida el pasado martes 12 de mayo con la madre del menor, la Sra. Elena Soler, no se ha observado mejoría en el aula. El Equipo de Orientación Educativa (EOEP) ha sido notificado por el Director del centro, Don Roberto Pando, clasificando el expediente como intervención prioritaria. Se ha solicitado formalmente la colaboración del Servicio de Salud del Principado de Asturias (SESPA) para coordinar la valoración psiquiátrica del menor antes del viernes 19 de junio. Nota: Toda la documentación clínica de Alejandro ha sido archivada en el aplicativo institucional SAECE según el protocolo de la Consejería de Educación.

No creo que sea necesario decirlo, pero para que no quepa duda: se trata de un texto generado por IA, totalmente inventado, por lo que ninguno de los datos personales son reales, aunque se presentan de forma que lo parezca, precisamente por motivos de aprendizaje. También he de decir que siendo su origen el que es, determinados contenidos o, mejor aun, lo que se peude inferir de ellos, no están correctamente expuestos, pero la verdad es que esta cuestión es ahora irrelevante.

Lo que no es irrelevante, y sí necesario antes de entrar realmente en materia es que procedamos a realizar una limpieza básica de errores manifiestos mediante un procedimiento ya conocido, aunque matizado en este caso, ya que por el momentos necesitamos mantener las mayúsculas, las tildes y los signos de puntuación, por lo que esta limpieza se limitará a la eliminación de código irrelevante y a corregir otros errores como la separación de palabras



import re

# Texto de trabajo

texto_base = """

El alumno Alejandro Martínez Soler, escolarizado en 4º de Primaria del CEIP San Juan Bautista de Oviedo, presenta dificultades de adaptación significati_vas. La tutora, Doña Carmen Villalobos, informa de que tras la reunión mantenida el pasado martes 12 de mayo con la madre del menor, la Sra. Elena Soler, no se ha observado mejoría en el aula. El Equipo de Orientación Educativa (EOEP) ha sido notificado por el Director del centro, Don Roberto Pando, clasificando el expediente como intervención prioritaria. Se ha solicitado formalmente la colaboración del Servicio de Salud del Principado de Asturias (SESPA) para coordinar la valoración psiquiátrica del menor antes del viernes 19 de junio. Nota: Toda la documentación clínica de Alejandro ha sido archivada en el aplicativo institucional SAECE según el protocolo de la Consejería de Educación.

""" # --- Función de limpieza ------------------------------------------------------- def limpieza_estructural_formato(texto): """ FASE 1. Elimina ruido de formato, etiquetas y repara la estructura física del texto sin alterar la gramática, la puntuación ni la capitalización. """ print("--- EJECUTANDO FASE 1: LIMPIEZA ESTRUCTURAL Y DE FORMATO ---") # 1. Eliminar marcado de etiquetas estructuradas (HTML, XML, componentes de interfaz) texto = re.sub(r'<[^>]+>', ' ', texto) # 2. Reparar palabras truncadas por guiones bajos o de final de línea ("significati_vas" -> "significativas") texto = re.sub(r'(\w+)_\s*(\w+)', r'\1\2', texto) # 3. Eliminar caracteres huérfanos de formato residuales (guiones bajos) texto = texto.replace('_', '') # 4. Normalización final de espacios en blanco y saltos de página/línea (aplana el texto en una sola línea continua) texto = re.sub(r'\s+', ' ', texto).strip() return texto # --- Llamada a la función -------------------------------------------------------- if __name__ == "__main__": texto_sintactico_puro = limpieza_estructural_formato(texto_base) print("\n[SALIDA FASE 1] TEXTO PREPARADO PARA PROCESAR:") print(texto_sintactico_puro)
Veamos el resultados:
El alumno Alejandro Martínez Soler, escolarizado en 4º de Primaria del CEIP San Juan Bautista de Oviedo, presenta dificultades de adaptación significativas. La tutora, Doña Carmen Villalobos, informa de que tras la reunión mantenida el pasado martes 12 de mayo con la madre del menor, la Sra. Elena Soler, no se ha observado mejoría en el aula. El Equipo de Orientación Educativa (EOEP) ha sido notificado por el Director del centro, Don Roberto Pando, clasificando el expediente como intervención prioritaria. Se ha solicitado formalmente la colaboración del Servicio de Salud del Principado de Asturias (SESPA) para coordinar la valoración psiquiátrica del menor antes del viernes 19 de junio. Nota: Toda la documentación clínica de Alejandro ha sido archivada en el aplicativo institucional SAECE según el protocolo de la Consejería de Educación.

Es muy imporante en este caso que el nuevo texto, además de "limpio" se presente como un único bloque de contenido, ya que de este modo podemos abordar el paso siguiente que consiste en la identificación de entidades, entendiendo por entidad una entidad una unidad básica dentro de un texto que posee un significado semántico específico y pertenece a una categoría predefinida, siendo, de hecho, la categorización el objetivo definitorio del procedimiento (personas (nombres de individuos), organizaciones (empresas, agencias, instituciones o grupos), lugares/ubicaciones...). Al conjunto de procedimientos empleados en la identificación de estas entidades se denomina NER (en inglés) - REN en español (Reconocimiento de Entidades Nombradas).

Por situarnos, NER/REN pertenencen al ámbito del NLP (en inglés) / PLN (en español Procesamiento del Lenguaje Natural) y, al igual que sucede en otros subcampos del PLN, se han desarrollado procedimientos basados en reglas o en modelos estadísticos (estos dentro del ML), pero también procedimientos Deep Learning (DL) discriminativos (modelos BERT) y finalmente mediante IA-Generativa, identificada como subcampo del DL, que aborda el NER transformando la extracción en una tarea de generación de texto nuevo estructurado a partir de un enunciado en lenguaje natural.

Aunque los primeros procedimientos NER se basaban exclusivamente en la implementación de reglas, el desarrollo de esta línea de investigación ha derivado a procesimientos que agilizan el trabajo en forma de bibliotecas, como es el caso de la biblioteca SpaCy, entorno de trabajo que empaqueta e integra varios enfoques (reglas, modelos estadísticos y DL discriminativo) y que pertenece al ecosistema Python. Esta será la referencia que tomaremos como punto de inicio para no inventar la rueda, por más que hacerlo no deje de tener interés didáctico, pero las limitaciones de tiempo mandan.

Planteo a continuación un script que pretende dar el salto hacia la NER mediante la aplicación de un segundo script Python-SpaCy al texto que limpiamos con el script anterior.



import spacy

# 1. Cargamos el modelo en español en local
nlp = spacy.load("es_core_news_md")

# 2. Cargamos el texto simulado limpio
texto_expediente = (
    "El alumno Alejandro Martínez Soler, escolarizado en 4º de Primaria del "
    "CEIP San Juan Bautista de Oviedo, presenta dificultades de adaptación significativas. "
    "La tutora, Doña Carmen Villalobos, informa de que tras la reunión mantenida "
    "el pasado martes 12 de mayo con la madre del menor, la Sra. Elena Soler, "
    "no se ha observado mejoría en el aula. El Equipo de Orientación Educativa (EOEP) "
    "ha sido notificado por el Director del centro, Don Roberto Pando, clasificando "
    "el expediente como intervención prioritaria. Se ha solicitado formalmente la "
    "colaboración del Servicio de Salud del Principado de Asturias (SESPA) para "
    "coordinar la valoración psiquiátrica del menor antes del viernes 19 de junio. "
    "Nota: Toda la documentación clínica de Alejandro ha sido archivada en el "
    "aplicativo institucional SAECE según el protocolo de la Consejería de Educación."
)

# 3. Procesamos el texto mediante SpaCy (tokeniza, analiza la sintaxis y extrae entidades)
doc = nlp(texto_expediente)

print("=== ENTIDADES NOMBRADAS DETECTADAS ===\n")

# 4. Iteramos sobre las entidades encontradas por el modelo
for entidad in doc.ents:
    print(f"Texto: {entidad.text:<50} | Categoría: {entidad.label_}")


=== ENTIDADES NOMBRADAS DETECTADAS ===
Texto: Alejandro Martínez Soler | Categoría: PER
Texto: Primaria del CEIP San Juan Bautista de Oviedo | Categoría: LOC
Texto: La tutora | Categoría: MISC
Texto: Doña Carmen Villalobos | Categoría: PER
Texto: Sra. Elena Soler | Categoría: PER
Texto: El Equipo de Orientación Educativa | Categoría: MISC
Texto: EOEP | Categoría: MISC
Texto: Director del centro | Categoría: MISC
Texto: Don Roberto Pando | Categoría: LOC
Texto: Servicio de Salud del Principado de Asturias | Categoría: ORG
Texto: SESPA | Categoría: ORG
Texto: Nota: Toda la documentación clínica | Categoría: MISC
Texto: Alejandro | Categoría: PER
Texto: SAECE | Categoría: MISC
Texto: Consejería de Educación | Categoría: ORG

Como puedes apreciar, el resultado que obtenemos deja mucho que desear, empezando por las ausencias y siguiendo por los errores. Sin ser muy exhaustivo...

  • El número de categorías es claramente insuficientes (sólo 4)
  • No captura las fechas en ninguno de sus formatos
  • Fusiona entidades: donde hay dos (o más) puede encontrar sólo una (Primaria del CEIP San Juan Bautista de Oviedo)
  • Confunde entidades (Don Roberto Pando| Categoría: LOC)

Por suerte disponemos de recursos para paliar estas limitaciones. De ello nos ocuparemos en próximas entradas

lunes, 1 de junio de 2026

DATOS. Tratamiento de datos

Datos no estructurados (III)

Simplificación (lematización)

Siguiendo la lógica de las entradas anteriores, lo que implica mantenerse dentro del enfoque BoW, aun podemos (y en buena lógica, debemos) ejecutar un segundo procedimiento de simplificación de la carga léxica, reduciendo el número total de palabras que componen "la bolsa". Este procedimiento también puede ser visto como una segunda forma de normalización en cuanto supone eliminar la variabilidad morfológica de las palabras.

Llamamos lematización a esta tercera fase del tratamiento de los datos textual no estructurados y consiste en reducir las palabras a una única representación de la posible variación de formas que derivan de las reglas morfológicas del lenguaje. La consecuencia esperada es reducir la posible variabilidad morfológica que se pueda dar en el texto con el que estemos trabajando, aunque tiene más sentido (y mayor rendimiento) cuando trabajamos con cierto número de textos.

El texto que tomamos ahora como texto de partida, es el resultante del proceso previo de simplificación léxica:

alumno presenta comportamiento atipico aula observa incremento ansiedad clases matematicas orientador indica caso urgente requiere atencion inmediata informe padres no co operan pautas proximo miercoles realizara evaluacion psicologica nota importante no debe clasificar menor evidencias científicas

El principal efecto de la aplicación a este texto de la tercera fase del proceso es reducir el la dispersión del vocabulario y su consecuencia: la simplificación de las dimensiones de la matriz de palabras. Un ejemplo, palabras como las sigueintes ("presenta", "presentó", "presentan" o "presentación") se registran como términos diferentes en la bolsa de palabras, aumentando su tamaño y el número de dimensiones de la matriz derivada. Para reducir estas variaciones morfológicas a un único elemento único podemos optar por dos técnicas:

  • La lematización, que reduce la palabra a su forma canónica o de diccionario (el infinitivo para los verbos, el masculino singular para sustantivos y adjetivos).
  • El truncamiento (stemming), que consiste en corta los sufijos de la palabra para quedarse con su raíz ("psicolog" representa "psicológica", "psicólogo"...).
Aunque podemos aplicar cualquiera de los dos procedimientos, el usado con más frecuencia (por razones obvias de naturaleza semántica) es la lematización, y la herramienta más empleada para llevarla a cabo es la biblioteca spacy, debido a que nltk es adecuada para el truncamiento, pero requiere adaptaciones de cierta complejidad para realizar lematización en español.


import spacy

# 1. Cargar el modelo en español de SpaCy (descarga previa con: python -m spacy download es_core_news_sm)

nlp = spacy.load("es_core_news_sm")

# 2. Texto original

texto = (
    "alumno presenta comportamiento atipico aula observa incremento ansiedad clases "
    "matematicas orientador indica caso urgente requiere atencion inmediata informe "
    "padres no co operan pautas proximo miercoles realizara evaluacion psicologica "
    "nota importante no debe clasificar menor evidencias científicas"
)

# 3. Procesar el texto con SpaCy

doc = nlp(texto)

# 4. Extraer los lemas (Filtramos preventivamente espacios extra o puntuación, aunque en este texto es innecesario)

palabras_lematizadas = [token.lemma_ for token in doc if not token.is_punct and not token.is_space]

# 5. Mostrar el resultado como una lista y como texto unificado

print("--- Lista de palabras lematizadas ---")
print(palabras_lematizadas)

print("\n--- Texto lematizado ---")
print(" ".join(palabras_lematizadas))


Lo nuevo del procedimiento se concreta en el procesamiento del texto con SpaCy mediante doc = nlp(texto), habiendo sido asignado a nlp el modelo Spacy previamente cargado nlp = spacy.load("es_core_news_sm") y la extracción de los lemas palabras_lematizadas = [token.lemma_ for token in doc], aunque en nuestra instrucción hemos incluído la limpieza preventiva de espacios y signos de puntuación if not token.is_punct and not token.is_space]. Debo recordar que este procedimiento, como se observa en la salida posterior, implica la tokenización del contenido del texto.

El resultado que obtenemos (después de recomponer el texto print(" ".join(palabras_lematizadas)) es el texto siguiente, que puedes comparar con el previo.

alumno presentar comportamiento atipico aula observar incremento ansiedad clase matematica orientador indicar caso urgente requerir atencion inmediato informe padr no co operar pauta proximo miercol realizar evaluacion psicologico nota importante no deber clasificar menor evidencia científico