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

lunes, 15 de junio de 2026

DATOS. Tratamiento de datos

Datos no estructurados (VIII)

Reconocimiento de entidades (NER) (VII)

Continuamos ahora la entrada anterior. En ella planteamos que los SLM son adecuados para extraer las NER en local, permitiendo que el tratamiento dependa exclusivamente del blindaje de nuestro propio equipo y no de servicios en la nube. No obstante observamos que esos modelos pequeños de lenguaje presentan determinadas limitaciones en la gestión de tareas de cierta complejidad. En esta entrada abordaremos cómo superar esos techos mediante la segmentación de procesos y el desarrollo de un modelo mixto.

Los resultados del empleo directo del SLM demostraron que las limitaciones de los obtenidos mediante el script no se debían a una incapacidad para extraer datos (capacidad del modelo para leer el texto), sino a la falta de recursos para abordar simultáneamente dos tareas: esa lectura y la estructuración del formato JSON.

Es cierto que existe un margen de mejora mediante el ajuste del prompt haciendo uso de estrategias de prompt engineering, y es conveniente investigar este enfoque; pero cuando la causa radica en las restricciones estructurales, la mejora del prompt no es la solución: los datos de uso indican claramente que el cuello de botella no está en la claridad de las instrucciones, sino en las limitaciones de la memoria de trabajo del modelo.

Además pudimos comprobar en la práctica que modificaciones de la redacción de prompt, lejos de mejorar los resultados, solo trasladaban la causa del fallo de un punto a otro sin resolver el problema de fondo. Esto provocaba un comportamiento pendular de ineficiencia del que era imposible salir. Sólo el cambio de perspectiva consiguió una mejora significativa: la segmentación de procesos.

Este es el script unificado de ambas fases, resultante de la fusión en uno de lo que originalmente fueron dos script diferenciados aunque relacionados.



import json
import re
from typing import Literal
from ollama import chat
from pydantic import BaseModel, create_model

# ==========================================
# 0. CONFIGURACIÓN Y DATOS DE ENTRADA
# ==========================================

MODELO = 'llama3.2:3b'

texto_analizar = (
    "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."
)

# ==========================================
# 1. FASE 1: EXTRACCIÓN DE BLOQUES SEMÁNTICOS
# ==========================================

PROMPT_SISTEMA_FASE1 = """
[OBJETIVO]
Localiza y extrae las frases, nombres, fechas e instituciones que representan entidades clave en el texto.
Devuelve el resultado como una lista de texto plano, un elemento por línea, precedido por '- '.

[REGLAS]
1. Extrae los bloques semánticos completos (ej: si hay un nombre con tratamiento de cortesía o cargo, extráelo completo).
2. NO incluyas introducciones, notas ni explicaciones.
3. Conserva las mayúsculas y siglas idénticas al original.

[EJEMPLO]
INPUT: El orientador del CEIP Marcelo Gago de Avilés remitió el informe técnico a la Consejería el pasado lunes 5 de octubre.
OUTPUT:
- El orientador
- CEIP Marcelo Gago
- Avilés
- la Consejería
- lunes 5 de octubre
"""

print("Ejecutando Fase 1 (Extracción de Bloques Semánticos)...")

response_fase1 = chat(
    model=MODELO,
    messages=[
        {'role': 'system', 'content': PROMPT_SISTEMA_FASE1},
        {'role': 'user', 'content': f"INPUT: {texto_analizar}"}
    ],
    options={'temperature': 0.0, 'top_p': 0.1}
)

salida_bruta = response_fase1.message.content
lines = [line.strip().lstrip('- ').strip() for line in salida_bruta.strip().split('\n') if line]

# --- CAPA DE LIMPIEZA DETERMINISTA (PYTHON) ---
STOP_WORDS_BLOQUES = {
    'alumno', 'menor', 'tutora', 'reunión', 'madre', 'director', 
    'aplicativo institucional', 'intervención prioritaria', 'informe'
}

lista_entidades_fase1 = []
for cadena in lines:
    # 1. Eliminar artículos y fórmulas de cortesía al inicio mediante regex
    procesada = re.sub(r'^(el|la|los|las|del|de|don|doña|sr|sra|sra\.)\s+', '', cadena, flags=re.IGNORECASE)
    
    # 2. Filtrar si coincide con una stop word o si quedó vacía
    if procesada.lower() not in STOP_WORDS_BLOQUES and len(procesada) > 2:
        lista_entidades_fase1.append(procesada)

print(f"-> Fase 1 Finalizada. Se detectaron {len(lista_entidades_fase1)} entidades potenciales.")


# ==========================================
# 2. FASE 2: CLASIFICACIÓN SEMÁNTICA (NER)
# ==========================================

# Definimos la taxonomía estricta mediante Literal
CategoriasNER = Literal[
    'ALUMNO', 'ROL_PROFESIONAL', 'CENTRO_EDUCATIVO', 
    'UBICACION', 'FAMILIAR', 'FECHA', 'ORGANISMO', 'APLICATIVO'
]

# Creamos el esquema Pydantic dinámico usando la salida real de la Fase 1
EsquemaClasificacion = create_model(
    'EsquemaClasificacion',
    __base__=BaseModel,
    **{entidad: (CategoriasNER, ...) for entidad in lista_entidades_fase1}
)

PROMPT_SISTEMA_FASE2 = """
[TASK]
Clasifica cada uno de los términos de la lista proporcionada asignándole exclusivamente una de las categorías permitidas.

[CATEGORÍAS PERMITIDAS]
- ALUMNO: Nombres de estudiantes.
- ROL_PROFESIONAL: Cargos, puestos, figuras docentes o equipos de orientación (individuales o colectivos).
- CENTRO_EDUCATIVO: Nombres de escuelas o colegios.
- UBICACION: Localidades, ciudades o provincias.
- FAMILIAR: Relaciones familiares o nombres de familiares del alumno.
- FECHA: Expresiones temporales.
- ORGANISMO: Servicios públicos, organismos de salud o consejerías.
- APLICATIVO: Software o plataformas informáticas.

[CRITICAL RULES]
1. Responde única y exclusivamente con el JSON estructurado según el esquema.
2. No agregues introducciones, notas ni texto fuera del JSON.

[EJEMPLO DE EJECUCIÓN]
INPUT LIST:
- CEIP Marcelo Gago
- Avilés
- lunes 5 de octubre
OUTPUT JSON:
{
    "CEIP Marcelo Gago": "CENTRO_EDUCATIVO",
    "Avilés": "UBICACION",
    "lunes 5 de octubre": "FECHA"
}

[FIN DEL EJEMPLO]
"""

# Convertimos la lista depurada en texto plano para el prompt
input_model_fase2 = "\n".join([f"- {item}" for item in lista_entidades_fase1])

print("\nEjecutando Fase 2 (Clasificación Semántica Estructurada)...")

response_fase2 = chat(
    model=MODELO,
    messages=[
        {'role': 'system', 'content': PROMPT_SISTEMA_FASE2},
        {'role': 'user', 'content': f"INPUT LIST:\n{input_model_fase2}"}
    ],
    format=EsquemaClasificacion.model_json_schema(),
    options={'temperature': 0.0}
)

# ==========================================
# 3. SALIDA FINAL CONSOLIDADA
# ==========================================

try:
    clasificacion_final = json.loads(response_fase2.message.content)
    print("\n--- Resultado Final Consolidado (NER Estructurado) ---")
    print(json.dumps(clasificacion_final, indent=4, ensure_ascii=False))
except Exception as e:
    print(f"\nError al estructurar el JSON final: {e}")
    print("Respuesta cruda del modelo:", response_fase2.message.content)


Este script presenta una arquitectura ajustada a los modelos de lenguaje pequeños y a sus limitaciones. En lugar de pedirle al modelo que extraiga y clasifique todo en un solo paso, el script implementa un patrón de diseño conocido como pipeline de dos fases con validación estricta y que obedece al siguiente esquema:

En la fase 1 se procede a la extracción de los datos mediante SLM y un prompt localizador de texto" que exige al modelo que extraiga bloques semánticos completos. Se definen hiperparámetros (temperature: 0.0 y top_p: 0.1) que fuerza al modelo a un comportamiento determinista y a ceñirse estrictamente al texto.

Además de la extracción de texto, en la fase 1 implementamos una capa híbrida de limpieza determinista mediante Python puro para que asuma funciones de limpieza de ruido en las que este modelo es mucho más eficiente que un modelo de lenguaje. Para esa limpieza empleamos estructuras RegEx (re.sub) y Stop Words (STOP_WORDS_BLOQUES

La fase 2 se inicia con la creación de un modelo dinámico basado en Pydantic. En este punto, el script genera en tiempo de ejecución una estructura de datos adaptada a las entidades encontradas en la fase anterior, definiendo un conjunto estricto de categorías permitidas.

Tras este proceso, de nuevo interviene el SLM para ejecutar el prompt de clasificación. Gracias a que el esquema de Pydantic se inyecta directamente en el motor de inferencia, el modelo queda restringido a devolver, única y exclusivamente, el JSON estructurado con el NER resultante, que es el siguiente:

{
"Alejandro Martínez Soler": "ALUMNO",
"CEIP San Juan Bautista": "CENTRO_EDUCATIVO",
"Oviedo": "UBICACION",
"Carmen Villalobos": "FAMILIAR",
"madre del menor": "FAMILIAR",
"Sra. Elena Soler": "FAMILIAR",
"pasado martes 12 de mayo": "FECHA",
"Roberto Pando": "ALUMNO",
"Equipo de Orientación Educativa (EOEP)": "ROL_PROFESIONAL",
"Director del centro": "ROL_PROFESIONAL",
"Servicio de Salud del Principado de Asturias (SESPA)": "ORGANISMO",
"viernes 19 de junio": "FECHA"
}

Como podemos observar en la salida del script, el formateo es impecable gracias al blindaje de Pydantic. Sin embargo, un modelo pequeño de lenguaje puede perder la relación de contexto y cometer errores importantes. Con todo, la mejora de resultados respecto al enfoque anterior es evidente, lo que demuestra que la segmentación del proceso y la combinación de Python + SLM presenta ventajas respecto a un modelo IA puro, ahorrando un 90% del trabajo de extracción.

Además, aun caben posibilidades de mejora de esta arquitectura modular... pero también plantear una tercera vía —la cual ya hemos esbozado en nuestro último script—: dividir el proceso de extracción de entidades combinando un modelo heurístico basado en SpaCy con otro basado en IA local, para que cada tecnología asuma las tareas en las que presenta mejor rendimiento.

Este será el comentido de la próxima entrada.

domingo, 14 de junio de 2026

DATOS. Tratamiento de datos

Datos no estructurados (VII)

Reconocimiento de entidades (NER) (VI)

Finalizaba la entrada anterior planteando la existencia de alternativas al modelo heurístico por pipeline híbrido, las cuales se concretan en varias opciones teóricas según vimos anteriormente). En realidad, esas opciones son eso, más teóricas que reales, ya que ni la basada en ML clásico ni la basada en DL discriminativo son realmente viables en función de las restricción reales de datos (confidencialidad, dificultad para conseguir los datos suficientes y coste del etiquetado) ni de hardware (limitaciones de procesamiento y de memoria) que puede afrontar un organismo con escasos recursos como es un SEO. La consecuencia es que sólo es viable la opción basada en IA generativa, pero únicamente la que se puede correr en local, lo que conlleva, además, una limitación en cuanto a los modelos de lenguaje a emplear, que no son otros que los modelos pequeños o SLM. Pero lo que esto no garantizada a priori es la viabilidad real y funcional del modelo IA local.

En consecuencia, la única alternativa viable es la IA generativa ejecutada en local. Esto conlleva, además, una restricción en los modelos de lenguaje a emplear, reduciéndolos a modelos pequeños o SLM. No obstante, esta viabilidad técnica no garantiza, a priori, la eficacia funcional del modelo de IA local para resolver un determinado problema. En el contexto de esta entrada y de las que la preceden, el problema a resolver es (sigue siendo) la extracción de las NER de un texto sintético que ya nos es conocido.

Como tendremos ocasión de comprobar en esta entrada, en la práctica pretender replicar la competencia de un LLM comercial online mediante el simple prompting de un SLM local es una vía infructuosa; las limitaciones en la capacidad de atención y la baja densidad de parámetros de estos modelos pequeños afectan radicalmente a su precisión en el desempeño de tareas como la extracción de datos a partir de un texto no estructurado. Por tanto, alcanzar una equivalencia funcional exige un cambio estructural en el procedimiento. Dado que el modelo en la nube queda descartado por motivos estrictos de confidencialidad, la solución real no radica en el aislamiento del modelo de lenguaje, sino en el diseño de una arquitectura híbrida: un pipeline robusto donde un modelo heurístico asuma la segmentación previa y la estructuración del texto, delegando en el SLM únicamente la extracción fina allí donde las reglas rígidas no alcanzan. El éxito, en consecuencia, pasa de depender de la potencia del modelo a depender de la inteligencia del proceso.

Dado que el tema requiere mucho más espacio del que podemos darle en esta entrada atendiendo a su propia finalidad, no me voy a detener en explicar qué es Ollama y cómo hemos llegado hasta el punto en que somos capaces de utilizar un modelo pequeño de lenguaje SLM como herramienta de trabajo; simplemente lo damos por ahora como un hecho , aunque nos tengamos que detener en algún detalle a la hora de explicar el funcionamiento del script.



# 0. Implementamos las bibliotecas necesarias: json, ollama, pydantic y typing

import json
from ollama import chat
from pydantic import BaseModel, Field
from typing import List

# 1. Definimos el esquema de salida esperado usando pydantic

class EntidadExtraida(BaseModel):
    texto: str = Field(description="El fragmento de texto exacto tal como aparece en el documento original.")
    etiqueta: str = Field(
        description=(
            "La categoría de la entidad. Debe ser una de las siguientes: "
            "'ALUMNO', 'ROL_PROFESIONAL', 'CENTRO_EDUCATIVO', 'UBICACION', "
            "'FAMILIAR', 'FECHA', 'ORGANISMO', 'APLICATIVO'."
        )
    )

class ResultadoNER(BaseModel):
    entidades: List[EntidadExtraida]

# 2. Entregamos el documento a procesar (obtener NER)

texto_analizar = (
    "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."
)

print("Enviando petición a llama3.2:3b...")

# 3. Llamada a Ollama utilizando salida estructurada (en el paso 1). Incluye la llamada al SLM y el prompt

response = chat(
    model='llama3.2:3b',
    messages=[
        {
            'role': 'system',
            'content': (
                "Eres un asistente experto en procesamiento del lenguaje natural especializado en el ámbito educativo. "
                "Tu tarea es realizar Reconocimiento de Entidades Nombradas (NER) sobre el texto proporcionado. "
                "Extrae únicamente las entidades literales que correspondan a estas categorías estrictas:\n"
                "- ALUMNO: Nombre del estudiante.\n"
                "- ROL_PROFESIONAL: Cargos, puestos o figuras docentes/técnicas (ej. tutora, Director, Equipo de Orientación Educativa, EOEP).\n"
                "- CENTRO_EDUCATIVO: Nombres de colegios o institutos.\n"
                "- UBICACION: Ciudades, municipios o provincias.\n"
                "- FAMILIAR: Nombres de padres, madres o tutores legales.\n"
                "- FECHA: Referencias temporales explícitas.\n"
                "- ORGANISMO: Entidades públicas, servicios de salud o consejerías.\n"
                "- APLICATIVO: Software, plataformas web o bases de datos institucionales.\n"
                "Sé riguroso y extrae solo texto literal, sin omitir ni inventar nada."
            )
        },
        {
            'role': 'user',
            'content': f"Analiza el siguiente texto:\n\n{texto_analizar}"
        }
    ],
    format=ResultadoNER.model_json_schema(), # Forzamos la estructura JSON mediante el esquema de Pydantic
    options={'temperature': 0.0}  			 # Forzamos a literalidad tratsando de evitar alucinaciones
)

# 4. Procesar y mostrar el resultado

try:
    datos_extraidos = json.loads(response.message.content)
    print("\n--- Entidades Detectadas por llama3.2:3b ---")
    print(json.dumps(datos_extraidos, indent=4, ensure_ascii=False))
except Exception as e:
    print(f"Error al parsear la respuesta: {e}")
    print("Respuesta cruda del modelo:", response.message.content)


Dentro de este script podemos identificar varias partes, empezando por las bibliotecas necesarias para su funcionamiento: json, ollama, pydantic y typing. Cada una de ellas aporta componentes fundamentales para el logro del objetivo propuesto: ollama proporciona el canal de comunicación con el modelo SLM, que es el encargado de leer y "comprender" el documento, pero las demás contribuyen determinando el correcto funcionamiento de éste para que el análisis y la salidad se ajuste estrictamente al objetivo.

El esquema de funcionamiento del script queda descrito por el orden de acciones que se recogen en los comentarios:

  • Primero definimos qué queremos obtener y cómo mediante pydantic y typing
  • Despues porporcionamos el material (texto o conjunto de datos no estructurados) sobre el que trabajar
  • En tercer lugar implementamos el SLM y el instrumento que permite la comunicación entre con él (Ollama), precisamente en orden inverso. En este punto se acoplan las directrices de rol del system prompt con el esquema JSON derivado de Pydantic, fijando la temperatura en 0.0 para garantizar el determinismo absoluto de la prueba.
  • finalmente procesamos los resultados y los mostramos en la consola, controlando cualquier posible error mediante una estructura try...except.

Una breve nota aclaratoria sobre el uso de una herramienta de IA como es Ollama dentro de este script, sin más pretensiones. El uso de un servicio Chat de IA generativa, como Gemini o Chat-GPT, puede dar a entender que esa es, si no la única, sí la principal forma de utilizar la IA, pero lo cierto es que existen otras formas que pasan por integrarla dentro de un script de Python (por ejemplo). Y esto es precisamente lo que hemos hecho en el script anterior; algo que, como podemos observar, resulta muy fácil de realizar en Python al contar con bibliotecas específicas, en nuestro caso from ollama import chat .

En sentido estricto y sumamente restringido, import ollama y...



response = chat(
    model='llama3.2:3b'
    

... son las instrucciones que hacen posible que usemos Ollama response = chat( y el SLM seleccionado model='llama3.2:3b', al que sigue el prompt, que es fundamental para su implementación. Este prompt debe ajustarse a una redacción concreta, ya que de ella depende el funcionamiento efectivo del SLM. Esto es válido también para los LLM, pero aun más para un SLM, dadas sus características y limitaciones respecto a sus hermanos mayores. El prompt que implementamos en este script se puede considerar ajustado a esos condicionantes.



response = chat(
    model='llama3.2:3b',
    messages=[
        {
            'role': 'system',
            'content': (
                "Eres un asistente experto en procesamiento del lenguaje natural especializado en el ámbito educativo. "
                "Tu tarea es realizar Reconocimiento de Entidades Nombradas (NER) sobre el texto proporcionado. "
                "Extrae únicamente las entidades literales que correspondan a estas categorías estrictas:\n"
                "- ALUMNO: Nombre del estudiante.\n"
                "- ROL_PROFESIONAL: Cargos, puestos o figuras docentes/técnicas (ej. tutora, Director, Equipo de Orientación Educativa, EOEP).\n"
                "- CENTRO_EDUCATIVO: Nombres de colegios o institutos.\n"
                "- UBICACION: Ciudades, municipios o provincias.\n"
                "- FAMILIAR: Nombres de padres, madres o tutores legales.\n"
                "- FECHA: Referencias temporales explícitas.\n"
                "- ORGANISMO: Entidades públicas, servicios de salud o consejerías.\n"
                "- APLICATIVO: Software, plataformas web o bases de datos institucionales.\n"
                "Sé riguroso y extrae solo texto literal, sin omitir ni inventar nada."
            )
        },
        {
            'role': 'user',
            'content': f"Analiza el siguiente texto:\n\n{texto_analizar}"
        }
    ],
    

A priori, este diseño satisface los niveles de exigencia del prompting para trabajar con modelos de lenguaje:

  • Asignación de rol que ayuda al modelo a, probabilísticamente, "acotar" el vocabulario y entender el contexto institucional.
  • Instrucciones de restricción rigurosas mediante el uso de palabras ("únicamente", "categorías estrictas" y "solo texto literal, sin omitir ni inventar nada") que actuan como barreras efectivas contra las alucinaciones.
  • Taxonomía con ejemplos In-line, como en ROL_PROFESIONAL, donde se incluyen ejemplos del dominio (tutora, Director, EOEP), lo que permite acotar entidades (v.g. identificar si "Equipo de Orientación" es un rol o un organismo).

Hagamos ahora un sencillo ejercicio de comparación entre lo que nos revuelve el uso de este prompt con Gemini y su LLM...

ALUMNO: Alejandro Martínez Soler
ROL_PROFESIONAL: tutora, Carmen Villalobos, Equipo de Orientación Educativa, EOEP, Director, Roberto Pando
CENTRO_EDUCATIVO: CEIP San Juan Bautista
UBICACION: Oviedo
FAMILIAR: Elena Soler
FECHA: martes 12 de mayo, viernes 19 de junio
ORGANISMO: Servicio de Salud del Principado de Asturias, SESPA, Consejería de Educación
APLICATIVO: SAECE

... y el resultado de su empleo con Ollama + llama3.2:3b

- ALUMNO: Alejandro Martínez Soler
- ROL_PROFESIONAL:
* Tutora: Doña Carmen Villalobos
* Director: Don Roberto Pando
* Equipo de Orientación Educativa (EOEP)
- CENTRO_EDUCATIVO: CEIP San Juan Bautista de Oviedo
- UBICACION: Oviedo
- FAMILIAR:
* Elena Soler (madre del alumno)
* FECHA:
* pasado martes 12 de mayo
* viernes 19 de junio
- ORGANISMO: Servicio de Salud del Principado de Asturias (SESPA)
- APLICATIVO: SAECE

Resulta muy interesante comprobar que el modelo LLM no consigue mejores resultados que el SLM en la tarea propuesta (NER) y sobre un texto plano; esto refuerza la idea de que hoy en día es posible obtener buenos resultados en según que tareas trabajando con modelos pequeños en local, lo que hace posible desarrollar proyectos con estas tecnologías con confianza en los resultados que podemos obtener, facilitando así dar respuesta a condicionantes como son los derivados de la privacidad. no obstante estos modelos no son superiores, ni siquiera iguales, a los LLM en cuanto la tarea se complica o entramos en contextos más extensos o interconectados. Pero cada cosa a su tiempo. De momento nos podemos quedar con lo que hasta ahora nos resulta útil: para un NER, un SLM corriendo en local puede que no tenga mucho que envidiar a un LLM... incluso es posible que se inviertan los papeles...

Pero veamos qué obtenemos al aplicar nuestro script:

{
"entidades": [
{
"texto": "Alejandro Martínez Soler",
"etiqueta": "ALUMNO"
},
{
"texto": "Doña Carmen Villalobos",
"etiqueta": "ROL_PROFESIONAL"
},
{
"texto": "La Sra. Elena Soler",
"etiqueta": "FAMILIAR"
},
{
"texto": "Don Roberto Pando",
"etiqueta": "ROL_PROFESIONAL"
},
{
"texto": "CEIP San Juan Bautista de Oviedo",
"etiqueta": "CENTRO_EDUCATIVO"
},
{
"texto": "Oviedo",
"etiqueta": "UBICACION"
},
{
"texto": "SEPA",
"etiqueta": "ORGANISMO"
},
{
"texto": "SAECE",
"etiqueta": "APLICATIVO"
}
]
}

Es de destacar el cambio formal que se ha producido, ya que pasamos de un listado estructurado de elementos a la expresión formal de una estructura JSON. Pero además también podemos comprobar cómo el mismo SLM que antes extraía los datos solicitados, ahora comete muchos más errores y omisiones. La explicación hay que buscarla en la naturaleza del modelo (y sus limitaciones) enfrentada a las exigencias de la tarea: al ser texto y prompt los mismos, la diferencia estriba en la exigencia de devolver una estructura que obliga a prestar atención a muchos detalles de forma como es un JSON. Ante todo ello, un modelo pequeño de lenguaje es incapaz de ofrecer una respuesta de calidad, la misma que sí puede ofrecer si no se le exige más allá de sus posibilidades. Esto abre una segunda vía de trabajo o, si se prefiere, un diseño en el que el código Python se complemente eficientemente con la IA.

Hasta aquí esta entrada, que evidentemente queda inconclusa. Lo que resta, que no es poco, para la próxima.

NOTAS
1 Puede que sea necesario dedicar una entrada al tema, pero diré que, más por cacharrear que por otra cosa, lo cierto es que tengo instalado Ollama en mi ordenador y con este servicio una serie de modelos. La instalación de Ollama no supone ninguna dificultad y la instalación de los modelos tampoco, así que me voy a ahorrar en estos momentos.
2 Según Gemini, en sentido estricto, Ollama es un entorno de ejecución (runtime) y un gestor de modelos ligero y de código abierto, diseñado para empaquetar, desplegar y ejecutar Modelos de Lenguaje (LLMs y SLMs) de forma local. No es un modelo de inteligencia artificial en sí mismo, sino la infraestructura que permite interactuar con ellos sin dependencia de servicios en la nube.

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.

lunes, 8 de junio de 2026

DATOS. Tratamiento de datos

Datos no estructurados (V)

Reconocimiento de entidades (NER) (II)

Finalizamos la entrada anterior dejando planteadas las limitaciones del NER mediante SpaCy, pero habiendo indicado que existen formas de mejora. En esta entrada trataremos sobre estas formas, empezando por una visión de conjunto , en la que destacaremos los pros y los contras de cada opción.

1. Enfoque heurístico. Reglas y diccionarios.

Descripción Se basa en listas de palabras (lexicones) y plantillas rígidas (expresiones regulares o RegEx). Por ejemplo, si el texto dice "D./Dña. (palabra en mayúscula]", el sistema asume que es una persona (PER).

  • Fortaleza: Es 100% predecible y controlable. Si diseñas una regla para capturar fechas con el patrón DD/MM/AAAA, las capturará con precisión matemática. Consume recursos mínimos (milisegundos de CPU).
  • Debilidad: Es extremadamente rígido y ciego al contexto. Si un informe dice "Se derivó al centro Santiago", la regla no sabe si "Santiago" es el psicólogo, la ciudad o el nombre del colegio. Además, mantener miles de reglas manuales es inmanejable.

Esta primera alternativa se encuadra dentro del modelo de la IA basada en reglas. La arquitectura del sistema consiste en posicionar un conjunto de procedimientos deterministas —basados en diccionarios o reglas de expresiones regulares RegEx— al inicio del pipeline , justo antes de dar paso al procedimiento probabilístico de la librería SpaCy. De este modo, se aprovecha la ventaja fundamental de ésta: la seguridad y control absoluto sobre el resultado en estructuras predecibles (como fechas o códigos de centros). Sin embargo, presenta como limitación crítica una elevada rigidez, que vuelve al sistema ciego ante el contexto lingüístico, la polisemia o las variaciones narrativas.

2. Machine Learning clásico. Enfoque estadístico/discriminativo (CRF)

Descripción En lugar de escribir reglas "a mano", se le dan al algoritmo cientos, miles de ejemplos ya etiquetados por humanos. Modelos como los CRF (Conditional Random Fields) calculan la probabilidad de que una palabra sea una entidad basándose en las características de la palabra misma y de sus vecinas (si está en mayúsculas, si va detrás de un verbo, etc.).

  • Fortaleza Alta capacidad de generalizar. Empieza a entender el contexto probabilístico de las frases.
  • Debilidad Requiere "ingeniería de atributos" manual (indicarle explícitamente al algoritmo en qué fijarse) y sufre enormemente con la polisemia y las estructuras sintácticas complejas.

Esta segunda alternativa se encuadra dentro del modelo del Machine Learning clásico supervisado, situándose históricamente en la transición de la IA simbólica hacia los modelos probabilísticos autónomos. La arquitectura del sistema consiste en sustituir las reglas rígidas por un algoritmo estadístico —los ya indicados Campos Aleatorios Condicionales (CRF)— que se entrena localmente alimentándolo con un corpus masivo de textos (por ejemplo, documentos o informes elbarados por el SEO, previamente etiquetados a mano por expertos bajo el estándar conceptual BIO .
De este modo, se aprovecha su ventaja fundamental: la capacidad de generalización estadística y un consumo de recursos tan ínfimo que permite una ejecución 100% local y confidencial en hardware de oficina básico (sin GPU). Sin embargo, asume como limitación crítica una altísima dependencia del trabajo humano previo, exigiendo el tedioso etiquetado de miles de ejemplos y el diseño manual de atributos lingüísticos (feature engineering), además de mostrarse vulnerable ante la polisemia y las estructuras sintácticas complejas.

3. Modelo Deep Learning clásico o discriminativo. Modelos de Lenguaje tipo BERT

Descripción Entramos en la era de las redes neuronales profundas. El texto se transforma en vectores matemáticos complejos (embeddings) que capturan el significado semántico profundo de las palabras. Modelos como BERT o RoBERTa leen la frase entera (hacia delante y hacia atrás) para clasificar cada palabra (token classification).

  • Fortaleza Su comprensión del contexto es extraordinaria. Sabe perfectamente cuándo "Santiago" es un lugar, una persona o una institución analizando el tono y la estructura de la frase. Es un modelo discriminativo (no inventa, solo etiqueta).
  • Debilidad El modelo base viene entrenado con textos generales (prensa, Wikipedia). Si se enfrenta al lenguaje técnico, administrativo y complejo (por ejemplo, el de un informe de orientación), comete errores de bulto (lo mismo que te ocurre a spaCy estándar), obligándo a realizar un fine-tuning .

Esta tercera alternativa se encuadra dentro del modelo del Deep Learning discriminativo, representando el gran hito de la IA de finales de la década de pasada gracias a la arquitectura de Transformers. La arquitectura del sistema consiste en tomar un modelo de lenguaje profundo preentrenado en español (como BETO o BNE) y someterlo a un proceso de ajuste fino (fine-tuning) local en el tramo final del pipeline, especializándolo en la semántica técnica específica.
De este modo, se aprovecha su ventaja fundamental: una extraordinaria comprensión del contexto sintáctico y semántico, capaz de resolver de forma nativa la ambigüedad y la polisemia profunda sin necesidad de definir atributos manualmente. Sin embargo, presenta como limitación la brecha de confidencialidad y la dependencia de hardware industrial; el proceso de ajuste fino exige una alta potencia de cálculo (tarjetas GPU), inaccesible para ordenadores personales, lo que obliga a subir informes con datos de menores a entornos cloud externos, lo que vulnera los principios del secreto profesional; el resultado evidente es que no es una solución viable para un servicio de orientación (SEO).

4. IA generativa. Modelos de lenguaje grandes LLM vs. Modelos de lenguaje pequeños en local SLM

Definición Supone un cambio radical de paradigma. Ya no clasificamos palabras sobre el propio texto. El modelo de lenguaje (tanto grande como pequeño -como Llama 3 o Phi-3) lee el informe y, mediante instrucciones (prompting), genera un texto nuevo e independiente que contiene la información estructurada (por ejemplo, un JSON con las entidades extraídas).
  • Fortaleza Máxima flexibilidad lingüística. Posee un "sentido común" sintáctico brutal debido a su masivo preentrenamiento. Permite que las categorías emerjan de forma natural porque puedes pedirle en lenguaje corriente: "Extrae los agentes implicados y las medidas propuestas en este texto, definiendo tú mismo las categorías si es necesario".
  • Debilidad 1 Tanto LLM como SLM presentan riesgo de "alucinación" (puede inventar o deformar datos si no se acota bien el prompt) y alto consumo de recursos (comparados con los anteriores)
  • Debilidad 2 Salvo condiciones especiales de hartware (que es muy difícil que se cumplan), Los LLM se deben correr on-line, lo que compromete los textos con que se usan y hace inviable en la práctica el tratamiento de textos que contengan datos confidenciales
  • Debilidad 3 Los SLM se pueden ejecutar en local gracias a tecnologías accesibles de compresión, eliminando el problema de la confidencialidad. Aunque su capacidad generalista es menor que la de un LLM, demuestran una alta precisión en tareas de extracción siempre que se diseñe un prompt riguroso que acote sus respuestas y se guíe su contexto (por ejemplo, mediante arquitecturas RAG) .

Esta cuarta alternativa se encuadra dentro del modelo de la IA Generativa, un paradigma donde el PLN evoluciona desde la mera clasificación lingüística hasata la creación de texto estructurado. En la arquitectura del sistema, la estrategia consiste en posicionar un modelo de lenguaje pequeño (SLM) en un entorno de ejecución íntegramente local y aislado de la red; esto transforma la extracción en una tarea directa donde el modelo lee el texto proporcionado y genera de inmediato una respuesta estructurada (como un formato JSON) guiado por instrucciones en lenguaje natural (prompting).
De este modo, se aprovecha su ventaja fundamental: la máxima flexibilidad semántica y la supresión del trabajo de etiquetado previo, permitiendo que las categorías complejas emerjan de la propia narrativa en un entorno que blinda al 100% la confidencialidad. Esto es posible en una CPU convencional gracias a las técnicas de cuantización, un proceso de optimización de software que comprime el modelo para adaptarlo a hardware estándar. Sin embargo, este enfoque presenta como limitación crítica el riesgo inherente de alucinación, lo que obliga a estructurar de forma sumamente estricta las instrucciones y restricciones operativas (prompts) para acotar el margen de libertad matemática del modelo generativo.

Notas
1 En lo que resta de la entrada, este texto ha sido elaborado a partir de la consulta estructurada a Gemini y a partir de las propuestas textuales de esta IA generativa.
2Anglicismo utilizado para referirse a un proceso estructurado, flujo de trabajo o cadena de pasos automatizados que transforman un elemento inicial en un producto final.
3El estándar de etiquetado BIO, también conocido como formato Inside-Outside-Beginning o esquema Ramshaw-Marcus, es el método de anotación de texto más utilizado en el Procesamiento del Lenguaje Natural (PLN) para entrenar modelos de Reconocimiento de Entidades Nombradas (NER)
4 Ajuste fino con datos propios, que requiere un corpus documental etiquetado y recursos de computación.
5 RAG Generación Aumentada por Recuperación / Retrieval-Augmented Generation. Es una técnica de Inteligencia Artificial que busca información en los archivos que se le proporcionan y se la entrega al modelo de lenguaje junto con la pregunta. De este modo, la IA redacta su respuesta basándose estrictamente en los datos que contienen esos documentos, reduciendo al mínimo el riesgo de alucinación y garantizando el control sobre la información.

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

DATOS. Tratamiento de datos

Datos no estructurados (II)

Simplificación (stop words)

Siguiendo dentro de la lógica de tratamiento del texto como BoW (bolsa de palabras), buscando ahora la simplificación y significación de los elementos (las palabras), el paso lógico que sigue es la eliminación de las llamadas palabras irrelevantes (stop words).

Para el enfoque bolsa de palabras (BoW), un texto se reduce a las frecuencias de aparición de sus componentes (las palabras), sin prestar atención a la estructura ni al orden de las palabras. Es por ello que, bajo esta premisa, determinadas palabras ("el", "un", "en", "de", "la" o "los") van a aparecer en la mayoría de los textos, si no en todos, por lo que no resultan significativas de esos textos e incrementan innecesariamente el corpus con el que el algoritmo deberá trabajar. Eliminarlas es una opción posible y deseable. Posible porque no aportan nada al posterior análisis; deseable porque interfieren en éste: los algoritmos de clasificación o de agrupamiento podrían interpretar que dos textos son similares porque comparten palabras como las anteriores, siendo que éstas no son decisorias para determinar el contenido del texto. Si las eliminamos, por el contrario, se incrementa el valor semántico de las palabras que no son eliminadas y que sí tienen una fuerte carga conceptual, definitoria del contenido del texto ("comportamiento", "atipico", "ansiedad", "urgente", "evaluacion").

No obstante, también el uso de esta estrategias genera controversia, incluso sin salirse del marco del enfoque BoW (o casi): determinadas palabras, como las que implican negación, normalmente son consideradas palabras vacías, pero resultan altamenten definitorias del significado del mensaje: no es lo mismo decir "es disruptivo" que negarlo ("no es disruptivo"), y eso es precisamente lo que resulta de eliminar la negación si aplicamos un procedimiento como el que aquí se propone. Pero por otro lado mantener las negaciones rompe la lógica del planteamiento del modelo BoW, que se basa en el análisis estadístico-vectorial de las palabras "llenas" sin concesión alguna a la dismensión estructural-secuencial de las palabras en el texto. Puede que el enfoque BoW presente importantes limitaciones (actualmente se puede considerar que pertenece al pasado) pero dentro de sus límites y en el marco de sus modelos de análisis es coherente y funcional. Pensando en superarlo, habrá que hacerlo dando los pasos necesarios, no introduciendo parches ajenos a la lógica del modelo que, además, no terminan de resolver los problemas que pretenden resolver.

Aun así, desde una perspectiva fundamentalmente didáctica como la que preside esta entrada, voy a plantear varias opciones de desarrollo en términos de script python de tratamiento del procedimiento para que te queden disponibles y puedas realizar los análisis y las comparaciones que desees. Para empezar, recueda que nuestro texto de partida es el mismo que fue de llegada en la entrada anterior:

el alumno presenta un comportamiento atipico en el aula se observa un incremento de la ansiedad durante las clases de matematicas el orientador indica que el caso es urgente y requiere atencion inmediata ver informe en los padres no co operan con las pautas el proximo miercoles se realizara otra evaluacion psicologica nota importante no se debe clasificar al menor sin mas evidencias científicas

Expongo a continuación el script más simple y más próximo al contenido del texto a tratar.



from collections import Counter
import re
import pandas as pd

# 1. Texto de partida. En este caso el resultante de la limpieza y normalización anterior

texto_normalizado = (
    "el alumno presenta un comportamiento atipico en el aula se observa un incremento "
    "de la ansiedad durante las clases de matematicas el orientador indica que el caso "
    "es urgente y requiere atencion inmediata ver informe en los padres no co operan "
    "con las pautas el proximo miercoles se realizara otra evaluacion psicologica "
    "nota importante no se debe clasificar al menor sin mas evidencias cientificas"
)

# Lista de stop words ajustada a texto_normalizado y excluyendo "no"
stop_words_estandar = {
    'el', 'un', 'en', 'se', 'de', 'la', 'durante', 'las', 'que', 'es', 'y', 
    'al', 'con', 'para', 'por', 'una', 'los', 'otra', 'sin', 'mas', 'ver'
}

# --- 2. Función de reducción de stop words --------------------------------------

def analizar_frecuencias(texto):
    palabras = texto.split()
    total_palabras = len(palabras)
    
    # Contar ocurrencias
    contador = Counter(palabras)
    
    # Construimos un diccionario con los datos
    datos_frecuencia = []
    for palabra, count in contador.most_common():
        porcentaje = (count / total_palabras) * 100
        datos_frecuencia.append({
            'Palabra': palabra,
            'Frecuencia Absoluta': count,
            'Porcentaje (%)': round(porcentaje, 2)
        })
    
    return pd.DataFrame(datos_frecuencia), total_palabras


# (A) Análisis PRE-limpieza-------------------------
df_pre, total_pre = analizar_frecuencias(texto_normalizado)


# (B) NUCLEO DEL PROCESO: eliminar stop words----------------------------------------------

palabras_limpias = [p for p in texto_normalizado.split() if p not in stop_words_estandar]
texto_limpio = " ".join(palabras_limpias)

#------------------------------------------------------------------------------------------

# (C) Análisis POST-limpieza--------------------
df_post, total_post = analizar_frecuencias(texto_limpio)

# --- Script de visualización ---------------------------------------------------

print(f"--- ANÁLISIS PRE-LIMPIEZA (Total palabras: {total_pre}) ---")
print(df_pre.head(8))  # Mostramos las 8 más frecuentes

print(f"\n--- ANÁLISIS POST-LIMPIEZA (Total palabras: {total_post}) ---")
print(df_post.head(8))

print (f"\n--- TEXTO LIMPIO \n {texto_limpio}")


Observa que junto con el texto sobre el que trabajamos, también he incluído expresamente un conjunto de palabras vacías (stop word) que se ajusta al contenido de dicho texto, excluyendo las negaciones, aunque tu puedes incluirlas, si lo prefieres.

Este script, además de proceder a la eliminación de esas palabras vacías también nos obrece el recuento del total de palabras del texto original y del resultante, junto con el análisis de las ocho palabras más frecuentes en cada fase del procedo. Estos últimos datos son únicamente a nivel informativo, por lo que puedes eliminarlos del script si así lo deseas. Personalmente no lo recomiendo, ya que precisamente esta función prioriza y expresa el recuento antes y después de la eliminación de las stop word.

Pero lo verdaderamente interesante de la función es precisamente el procedimiento de eliminación de ese grupo de palabras vacías del texto original. Para ello estas dos instrucciones...



palabras_limpias = [p for p in texto_normalizado.split() if p not in stop_words_estandar]
texto_limpio = " ".join(palabras_limpias)


... desarrollan el segmentación del texto texto_normalizado extrayéndo palabra a palabra mediante el bucle for p in texto_normalizado y comprobando que dicha palabra no está en el grupo de palabras vacías stop_word_estandar mediante un condicional if p not in.... El resultado es una lista de palabras llenas que posteriormente se convierten en un texto sobre la variable texto_limpio que resulta de aplicar la función join() (en realidad "".join(palabras_limpias).

En realidad esta forma es una forma sintética y eficiente de plantear lo que en un script básico de python se expresaría como sigue:



# Primera expresión----------------------

palabras_limpias = []
for p in texto_normalizado.split():
    if p not in stop_words_estandar:
        palabras_limpias.append(p)

# Segunda expresión ---------------------

texto_limpio = ""

for palabra in palabras_limpias:
    if texto_limpio != "":  # Si la variable ya tiene texto, le sumamos un espacio antes de la siguiente palabra
        texto_limpio = texto_limpio + " "
    texto_limpio = texto_limpio + palabra   # Sumamos la palabra al texto
    

Vamos a ver ahora una segunda alternativa, empleando la biblioteca NLTK


from collections import Counter
import pandas as pd
import nltk
from nltk.corpus import stopwords

# Aseguramos la descarga del corpus oficial de stop words de NLTK

nltk.download('stopwords', quiet=True)

# 1. Texto normalizado de base

texto_normalizado = (
    "el alumno presenta un comportamiento atipico en el aula se observa un incremento "
    "de la ansiedad durante las clases de matematicas el orientador indica que el caso "
    "es urgente y requiere atencion inmediata ver informe en los padres no co operan "
    "con las pautas el proximo miercoles se realizara otra evaluacion psicologica "
    "nota importante no se debe clasificar al menor sin mas evidencias cientificas"
)


# 2. Carga del conjunto stopwords de NLTK en español (~300 palabras)

stop_words_completo = set(stopwords.words('spanish'))

# Especificamos SÓLO las palabras que SÍ se desean mantener (blindaje de negaciones/matices)

palabras_a_mantener = {'no', 'ni', 'sin', 'tampoco', 'jamas', 'nunca'}

# Creamos el filtro final: el conjunto completo menos nuestras excepciones

stop_words_eficiente = stop_words_completo.difference(palabras_a_mantener)


# 3. Función de recuento y cálculo porcentual

def analizar_y_formatear(texto):
    palabras = texto.split()
    total_palabras = len(palabras)
    contador = Counter(palabras)
    
    datos = []
    for palabra, count in contador.most_common():
        porcentaje = (count / total_palabras) * 100
        datos.append({
            'Palabra': palabra,
            'Frec. Absoluta': count,
            'Porcentaje': f"{porcentaje:.2f}%"
        })
    return pd.DataFrame(datos), total_palabras


# --- Script de ejecución: llamada a función  

# Fase A: Recuento Pre-Limpieza

df_pre, total_pre = analizar_y_formatear(texto_normalizado)

# Fase B: NÚCLEO DEL PROCEDIMIENTO: eliminación de stop words

palabras_filtradas = [p for p in texto_normalizado.split() if p not in stop_words_eficiente]
texto_limpio = " ".join(palabras_filtradas)

# Fase C: Recuento Post-Limpieza

df_post, total_post = analizar_y_formatear(texto_limpio)


# --- SALIDA POR CMD ---
# Configuración de visualización para la consola de Windows / CMD
pd.set_option('display.max_rows', 12)
pd.set_option('display.width', 1000)

print("\n" + "="*60)
print(f" AUDITORÍA DE FRECUENCIAS (MÉTODO NLTK) - TOTAL: {total_pre} ".center(60))
print("="*60)

print(f"\n[TOP 10] PALABRAS ANTES DE LA LIMPIEZA:")
print("-" * 45)
print(df_pre.head(10).to_string(index=False))

print("\n" + "-"*60)

print(f"\n[TOP 10] PALABRAS DESPUÉS DE LA LIMPIEZA (Total: {total_post}):")
print("-" * 45)
print(df_post.head(10).to_string(index=False))

print("\n" + "="*60)
print(" TEXTO RESULTANTE PARA BOLSA DE PALABRAS (BoW) ".center(60))
print("="*60)
print(texto_limpio)
print("="*60 + "\n")


La biblioteca NLTK dispone de varias funciones para el PLN, aunque ahora nos centraremos en la función de limpieza (eliminación) de las palabras vacías (stop words) usando la biblioteca NLTK. Primero nos aseguramos de cargar su conjunto stop words en español nltk.download('stopwords', quiet=True); después creamos un conjunto de excepciones palabras_a_mantener = {'no', 'ni', 'sin', 'tampoco', 'jamas', 'nunca'} y finalmente creamos el subconjunto de stop words que realmente vamos a aplicar stop_words_eficiente = stop_words_completo.difference(palabras_a_mantener).

Aunque en este script desarrollamos el proceso de eliminación de palabras vacías dentro del script principal, las instrucciones que se ejecutan son las mismas que en el script anterior...



# Fase B: NÚCLEO DEL PROCEDIMIENTO: eliminación de stop words

palabras_filtradas = [p for p in texto_normalizado.split() if p not in stop_words_eficiente]
texto_limpio = " ".join(palabras_filtradas)


... aunque ahora emplemos dentro del condicional el subconjunto que creamos antes if p not in stop_words_eficiente]. Dado que el texto sobre el que trabajamos es el mismo que el usado en el script anterior, el resultado que obtenemos es el mismo que si aplicamos el primer script de esta entrada.
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 cientificas

Sobra decir que si eliminamos el subconjunto de excepciones el texto resultante sería ligeramente diferente,más coherente con el planteamiento de trabajo del modelo BoW, pero aquí he preferido mantener estas excepciones para mostrar la instrucción que nos pertime incluir una excepción en el manejo de los contenidos de una colección de datos (función difference()).