feat(datascience): GLiNER entity extractor (zero-shot NER) drop-in con LLM
Funciones nuevas en python/functions/datascience/: - gliner_load_model: carga + cachea modelo GLiNER por (name, device). device='auto' resuelve a cuda/cpu segun torch.cuda.is_available, sin fallar si torch no esta instalado. ImportError claro si falta gliner. - extract_entities_gliner: contrato drop-in de extract_entities_llm (mismo entity_schema, mismo list[EntityCandidate]). El caller inyecta el modelo (cargado UNA vez por proceso). Anota offsets start/end en attributes para reconciliar con extract_iocs (issue 0040). Diferencias vs LLM extractor: - 50-200x mas rapido en GPU, 0 USD/token. - Malo con IoCs tecnicos (lo cubre 0037). - Threshold y flat_ner ajustables por dominio. pyproject.toml: gliner como extra opcional `[nlp]` para no inflar el .venv de quien no use NER. Instalacion: `uv pip install -e '.[nlp]'`. Refs #0038 — Desbloquea 0039 (GLiREL) y 0040 (pipeline hibrido). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
name: extract_entities_gliner
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def extract_entities_gliner(text: str, entity_schema: list[dict], model: Any, threshold: float = 0.5, flat_ner: bool = True) -> list[EntityCandidate]"
|
||||||
|
description: "Extrae entidades zero-shot con GLiNER. Drop-in del contrato de extract_entities_llm pero 50-200x mas rapido y sin coste por token. El caller inyecta el modelo cargado con gliner_load_model. Anota offsets start/end en attributes para reconciliar con extract_iocs."
|
||||||
|
tags: [gliner, ner, nlp, entity, extract, zero-shot, osint, graph, datascience, python]
|
||||||
|
uses_functions: [gliner_load_model_py_datascience]
|
||||||
|
uses_types: [entity_candidate_py_datascience]
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [warnings]
|
||||||
|
params:
|
||||||
|
- name: text
|
||||||
|
desc: "chunk de texto a analizar (parrafo, documento corto, output de OCR)"
|
||||||
|
- name: entity_schema
|
||||||
|
desc: "lista de dicts con 'type_ref' y 'label'. Mismo formato que extract_entities_llm. El 'label' se usa como label de GLiNER."
|
||||||
|
- name: model
|
||||||
|
desc: "instancia GLiNER cargada con gliner_load_model. Inyectar para evitar penalty de carga en batch."
|
||||||
|
- name: threshold
|
||||||
|
desc: "score minimo para aceptar una entidad (0.0-1.0). Defecto 0.5 — ajustable segun precision/recall objetivo."
|
||||||
|
- name: flat_ner
|
||||||
|
desc: "True (defecto) sin entidades anidadas; False permite spans solapados (ej. 'Universidad de Madrid' como ORG y 'Madrid' como LOC en simultaneo)"
|
||||||
|
output: "lista de EntityCandidate con name, type_ref, type_label, confidence y attributes={'start': int, 'end': int}"
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "Schema basico y modelo stub retorna EntityCandidate con offsets"
|
||||||
|
- "Threshold filtra spans con score bajo"
|
||||||
|
- "Schema vacio lanza ValueError"
|
||||||
|
- "Schema sin label+type_ref validos retorna vacio con warning"
|
||||||
|
- "Excepcion del modelo se captura y retorna vacio"
|
||||||
|
- "Label desconocido se descarta"
|
||||||
|
- "flat_ner se propaga al modelo"
|
||||||
|
test_file_path: "python/functions/datascience/tests/test_extract_entities_gliner.py"
|
||||||
|
file_path: "python/functions/datascience/extract_entities_gliner.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from python.functions.datascience import (
|
||||||
|
gliner_load_model,
|
||||||
|
extract_entities_gliner,
|
||||||
|
)
|
||||||
|
|
||||||
|
model = gliner_load_model(device="auto")
|
||||||
|
|
||||||
|
schema = [
|
||||||
|
{"type_ref": "osint_person_go_cybersecurity", "label": "Person"},
|
||||||
|
{"type_ref": "osint_organization_go_cybersecurity", "label": "Organization"},
|
||||||
|
{"type_ref": "osint_location_go_cybersecurity", "label": "Location"},
|
||||||
|
]
|
||||||
|
|
||||||
|
text = "Alice Johnson works at OpenAI in San Francisco."
|
||||||
|
entities = extract_entities_gliner(text, schema, model, threshold=0.4)
|
||||||
|
# [EntityCandidate(name='Alice Johnson', type_ref='osint_person_go_cybersecurity',
|
||||||
|
# attributes={'start': 0, 'end': 13}, confidence=0.92), ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Drop-in con extract_entities_llm
|
||||||
|
|
||||||
|
El retorno es identico (`list[EntityCandidate]`), por lo que se puede sustituir
|
||||||
|
sin tocar el resto del pipeline (`deduplicate_entities`, `merge_entity_attributes`,
|
||||||
|
etc). Diferencias:
|
||||||
|
|
||||||
|
- **Coste**: GLiNER = 0 USD/token. LLM = depende de modelo.
|
||||||
|
- **Latencia**: GLiNER 50-200x mas rapido en GPU.
|
||||||
|
- **IoCs tecnicos** (IPs, hashes, wallets, CVEs): GLiNER es malo — usar
|
||||||
|
`extract_iocs_py_cybersecurity` para esos. Combinar regex + GLiNER en
|
||||||
|
el pipeline hibrido (issue 0040).
|
||||||
|
- **Schemas con muchos tipos**: GLiNER pierde precision con >20 labels;
|
||||||
|
LLM la mantiene. Para esquemas grandes, dividir en bloques.
|
||||||
|
- **Razonamiento implicito** ("CEO de la empresa"): el LLM lo deduce, GLiNER
|
||||||
|
solo extrae lo explicito.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- El modelo se carga UNA vez por proceso. No cargarlo aqui dentro: penalty fatal
|
||||||
|
en batch. Inyeccion explicita por contrato.
|
||||||
|
- impure: el modelo es estado externo (memoria, GPU si aplica). `error_type:
|
||||||
|
error_go_core` segun la regla de pureza del registry.
|
||||||
|
- Si `flat_ner=False`, validar que el caller dedupica/normaliza spans solapados
|
||||||
|
— `EntityCandidate.attributes['start'/'end']` permite hacerlo facilmente.
|
||||||
|
- Para precision maxima, ajustar `threshold` por dominio: 0.3-0.4 para recall
|
||||||
|
alto, 0.6-0.8 para precision alta.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Extrae entidades de un chunk de texto usando GLiNER (zero-shot NER)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||||
|
|
||||||
|
from python.types.datascience.entity_candidate import EntityCandidate
|
||||||
|
|
||||||
|
|
||||||
|
def _build_label_maps(entity_schema: list[dict]) -> tuple[list[str], dict[str, str], dict[str, str]]:
|
||||||
|
"""Traduce el schema al formato que espera GLiNER.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
labels: lista de strings (lo que se pasa a model.predict_entities).
|
||||||
|
label_to_type_ref: dict para mapear el label predicho al type_ref.
|
||||||
|
label_to_label: dict label -> label legible (para `type_label`).
|
||||||
|
"""
|
||||||
|
labels: list[str] = []
|
||||||
|
label_to_type_ref: dict[str, str] = {}
|
||||||
|
label_to_label: dict[str, str] = {}
|
||||||
|
for entry in entity_schema:
|
||||||
|
label = entry.get("label", "").strip()
|
||||||
|
type_ref = entry.get("type_ref", "").strip()
|
||||||
|
if not label or not type_ref:
|
||||||
|
continue
|
||||||
|
labels.append(label)
|
||||||
|
# last-wins si dos type_refs comparten label.
|
||||||
|
label_to_type_ref[label] = type_ref
|
||||||
|
label_to_label[label] = label
|
||||||
|
return labels, label_to_type_ref, label_to_label
|
||||||
|
|
||||||
|
|
||||||
|
def extract_entities_gliner(
|
||||||
|
text: str,
|
||||||
|
entity_schema: list[dict],
|
||||||
|
model: Any,
|
||||||
|
threshold: float = 0.5,
|
||||||
|
flat_ner: bool = True,
|
||||||
|
) -> list[EntityCandidate]:
|
||||||
|
"""Extrae entidades zero-shot con GLiNER, contrato drop-in con `extract_entities_llm`.
|
||||||
|
|
||||||
|
Cada `entity_schema` entry usa su `label` como label de GLiNER. El
|
||||||
|
type_ref se reconstruye desde `label_to_type_ref`. Offsets de span
|
||||||
|
se anotan en `attributes["start"]` y `attributes["end"]` para que
|
||||||
|
el caller pueda reconciliar con regex IoCs (ver `extract_iocs`).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Chunk a analizar.
|
||||||
|
entity_schema: Misma estructura que `extract_entities_llm` —
|
||||||
|
lista de dicts con `type_ref` y `label`.
|
||||||
|
model: Instancia GLiNER cargada con `gliner_load_model`. Inyectada
|
||||||
|
por el caller para evitar penalty de carga en batch.
|
||||||
|
threshold: Score minimo para aceptar una entidad (0.0-1.0).
|
||||||
|
flat_ner: True = sin entidades anidadas. False = anidadas (puede
|
||||||
|
producir spans solapados).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de EntityCandidate. Vacia si el modelo no detecta nada o
|
||||||
|
si entity_schema queda sin labels validos tras filtrar.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Si entity_schema esta vacio.
|
||||||
|
"""
|
||||||
|
if not entity_schema:
|
||||||
|
raise ValueError("entity_schema no puede estar vacio")
|
||||||
|
|
||||||
|
labels, label_to_type_ref, label_to_label = _build_label_maps(entity_schema)
|
||||||
|
if not labels:
|
||||||
|
warnings.warn(
|
||||||
|
"extract_entities_gliner: ningun entry del schema tiene "
|
||||||
|
"label+type_ref validos; retornando vacio.",
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_entities = model.predict_entities(
|
||||||
|
text,
|
||||||
|
labels,
|
||||||
|
threshold=threshold,
|
||||||
|
flat_ner=flat_ner,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
warnings.warn(
|
||||||
|
f"extract_entities_gliner: error invocando model.predict_entities: {exc}",
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(raw_entities, list):
|
||||||
|
warnings.warn(
|
||||||
|
"extract_entities_gliner: predict_entities no retorno una lista; "
|
||||||
|
"retornando vacio.",
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
candidates: list[EntityCandidate] = []
|
||||||
|
for item in raw_entities:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
span_text = item.get("text", "")
|
||||||
|
label = item.get("label", "")
|
||||||
|
if not span_text or label not in label_to_type_ref:
|
||||||
|
continue
|
||||||
|
|
||||||
|
score = item.get("score", 0.0)
|
||||||
|
if not isinstance(score, (int, float)):
|
||||||
|
score = 0.0
|
||||||
|
confidence = float(max(0.0, min(1.0, score)))
|
||||||
|
|
||||||
|
start = item.get("start")
|
||||||
|
end = item.get("end")
|
||||||
|
attributes: dict = {}
|
||||||
|
if isinstance(start, int):
|
||||||
|
attributes["start"] = start
|
||||||
|
if isinstance(end, int):
|
||||||
|
attributes["end"] = end
|
||||||
|
|
||||||
|
candidates.append(
|
||||||
|
EntityCandidate(
|
||||||
|
name=span_text,
|
||||||
|
type_ref=label_to_type_ref[label],
|
||||||
|
type_label=label_to_label.get(label, label),
|
||||||
|
attributes=attributes,
|
||||||
|
confidence=confidence,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return candidates
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
name: gliner_load_model
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def gliner_load_model(model_name: str = 'urchade/gliner_multi-v2.1', device: str = 'auto') -> Any"
|
||||||
|
description: "Carga (y cachea por (model_name, device)) un modelo GLiNER zero-shot NER. La primera llamada descarga ~200 MB desde HuggingFace; sucesivas devuelven la instancia cacheada. device='auto' usa CUDA si esta disponible, o CPU."
|
||||||
|
tags: [gliner, ner, nlp, model, huggingface, zero-shot, datascience, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: model_name
|
||||||
|
desc: "ID del modelo en HuggingFace Hub (defecto: urchade/gliner_multi-v2.1, multilingue ES/EN)"
|
||||||
|
- name: device
|
||||||
|
desc: "'auto' (CUDA si disponible, sino CPU), 'cpu', 'cuda', 'cuda:N'"
|
||||||
|
output: "instancia GLiNER lista para predict_entities, cacheada por (model_name, device)"
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "ImportError si gliner no esta instalado"
|
||||||
|
- "Cache devuelve la misma instancia con los mismos parametros"
|
||||||
|
- "device='auto' resuelve a cpu o cuda segun torch.cuda.is_available"
|
||||||
|
test_file_path: "python/functions/datascience/tests/test_extract_entities_gliner.py"
|
||||||
|
file_path: "python/functions/datascience/gliner_load_model.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from python.functions.datascience import gliner_load_model
|
||||||
|
|
||||||
|
# Primera llamada descarga el modelo (~200 MB, una vez)
|
||||||
|
model = gliner_load_model(device="auto")
|
||||||
|
|
||||||
|
# Llamadas sucesivas con mismos params devuelven el cache
|
||||||
|
model_again = gliner_load_model(device="auto")
|
||||||
|
assert model is model_again
|
||||||
|
```
|
||||||
|
|
||||||
|
## Instalacion
|
||||||
|
|
||||||
|
GLiNER no esta en las dependencias principales del registry. Para usarlo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd python && uv pip install gliner # solo gliner
|
||||||
|
cd python && uv pip install -e '.[nlp]' # extra completo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tamaño y latencia
|
||||||
|
|
||||||
|
- `urchade/gliner_multi-v2.1`: ~210 MB en disco (modelo + tokenizer).
|
||||||
|
- Primera carga: 5-15 s en CPU, depende del disco y red.
|
||||||
|
- Inferencia CPU: 1-5 KB texto/s con 8 labels (Apple M2 / i7 moderno).
|
||||||
|
- Inferencia GPU (CUDA T4): 50-200 KB texto/s — 50-200x mas rapido.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- El cache es por (model_name, device): cargar el mismo modelo en CPU y CUDA crea dos instancias. Es intencional para permitir A/B.
|
||||||
|
- Si `torch` no esta instalado y `device='auto'`, cae a `'cpu'` sin error.
|
||||||
|
- Para limpiar el cache (memoria GPU): borrar entradas de `_MODEL_CACHE` directamente o reiniciar el proceso.
|
||||||
|
- impure: lee disco/red la primera vez y mantiene estado en `_MODEL_CACHE`.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Carga (y cachea) un modelo GLiNER en el device deseado."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Cache global: (model_name, device) -> modelo cargado.
|
||||||
|
_MODEL_CACHE: dict[tuple[str, str], Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_device(device: str) -> str:
|
||||||
|
"""Resuelve `device='auto'` a `cuda` o `cpu` segun disponibilidad."""
|
||||||
|
if device != "auto":
|
||||||
|
return device
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
except ImportError:
|
||||||
|
return "cpu"
|
||||||
|
return "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
|
||||||
|
|
||||||
|
def gliner_load_model(
|
||||||
|
model_name: str = "urchade/gliner_multi-v2.1",
|
||||||
|
device: str = "auto",
|
||||||
|
) -> Any:
|
||||||
|
"""Carga un modelo GLiNER con cache por (model_name, device).
|
||||||
|
|
||||||
|
La primera llamada descarga el modelo desde HuggingFace (~200 MB para
|
||||||
|
`gliner_multi-v2.1`). Llamadas sucesivas con los mismos parametros
|
||||||
|
devuelven la instancia cacheada.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_name: ID del modelo en HuggingFace Hub.
|
||||||
|
device: 'auto' usa CUDA si esta disponible, o 'cpu'/'cuda'/'cuda:N'
|
||||||
|
de forma explicita.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Instancia del modelo GLiNER lista para `predict_entities`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ImportError: si la dependencia `gliner` no esta instalada.
|
||||||
|
Solucion: `uv pip install gliner` o instalar el extra `nlp`
|
||||||
|
del proyecto (`uv pip install -e '.[nlp]'`).
|
||||||
|
"""
|
||||||
|
resolved_device = _resolve_device(device)
|
||||||
|
cache_key = (model_name, resolved_device)
|
||||||
|
cached = _MODEL_CACHE.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
try:
|
||||||
|
from gliner import GLiNER
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"gliner no esta instalado. Instalalo con "
|
||||||
|
"`uv pip install gliner` o `uv pip install -e '.[nlp]'`."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
model = GLiNER.from_pretrained(model_name)
|
||||||
|
if hasattr(model, "to"):
|
||||||
|
model.to(resolved_device)
|
||||||
|
_MODEL_CACHE[cache_key] = model
|
||||||
|
return model
|
||||||
@@ -19,6 +19,11 @@ dependencies = [
|
|||||||
"xlrd>=2.0.2",
|
"xlrd>=2.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
nlp = [
|
||||||
|
"gliner>=0.2.13",
|
||||||
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=9.0.2",
|
"pytest>=9.0.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user