Files
gliner_glirel_tuning/build_notebook_e2e.py
2026-05-04 23:44:11 +02:00

263 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Construye notebooks/02_e2e_spanish_graph.ipynb — E2E con texto castellano,
extract_graph_hybrid y visualizacion del grafo dentro del propio notebook.
"""
from __future__ import annotations
from pathlib import Path
import nbformat as nbf
HERE = Path(__file__).resolve().parent
NB_PATH = HERE / "notebooks" / "02_e2e_spanish_graph.ipynb"
def _md(text: str):
return nbf.v4.new_markdown_cell(text)
def _code(src: str):
cell = nbf.v4.new_code_cell(src)
cell.outputs = []
cell.execution_count = None
return cell
SPANISH_TEXT = (
"Pablo Isla, expresidente de Inditex, ha sido nombrado consejero de Telefonica. "
"La operacion fue anunciada por el presidente Jose Maria Alvarez-Pallete en Madrid el pasado lunes. "
"Inditex factura mas de 30.000 millones anuales y tiene su sede en Arteixo, A Coruna.\n\n"
"En paralelo, Iberdrola y Endesa firmaron un acuerdo de colaboracion en proyectos eolicos en Galicia. "
"El presidente de Iberdrola, Ignacio Galan, se reunio con la CEO de Endesa, Marina Serrano, en Bilbao. "
"El acuerdo movilizara 2.000 millones de euros en cinco anos.\n\n"
"El BBVA, presidido por Carlos Torres, mostro interes en participar en la financiacion del proyecto. "
"Su sede central esta en Bilbao."
)
def build():
cells = []
cells.append(_md(
"# E2E — texto castellano → grafo en el notebook\n\n"
"Validacion end-to-end del flujo del panel _Paste & Extract_ usando los thresholds "
"calibrados en `01_gliner_glirel_tuning.ipynb`:\n\n"
"- `entity_threshold = 0.50`\n"
"- `relation_threshold = 0.15`\n"
"- `relation_labels` en snake_case corto\n\n"
"Pegamos un texto en castellano sobre el sector empresarial espanol, corremos el pipeline "
"`extract_graph_hybrid`, y dibujamos el grafo resultante con `networkx + matplotlib`."
))
cells.append(_md("## 1. Setup"))
cells.append(_code(
"import os, sys, json, warnings\n"
"warnings.filterwarnings('ignore')\n"
"os.environ.setdefault('HF_HUB_DISABLE_PROGRESS_BARS', '1')\n"
"from pathlib import Path\n"
"\n"
"# Limpiar sys.path: el startup del kernel anade cada subdir de\n"
"# python/functions/, y bigquery/datasets.py sombrea al paquete\n"
"# `datasets` de HuggingFace. Dejamos solo el padre para imports\n"
"# 'from datascience...' / 'from pipelines...' al estilo paquete.\n"
"_pf = '/home/lucas/fn_registry/python/functions'\n"
"sys.path = [p for p in sys.path if not p.startswith(_pf + '/')]\n"
"if _pf not in sys.path:\n"
" sys.path.insert(0, _pf)\n"
"\n"
"import pandas as pd\n"
"import networkx as nx\n"
"import matplotlib.pyplot as plt\n"
"from datascience.gliner_load_model import gliner_load_model\n"
"from datascience.glirel_load_model import glirel_load_model\n"
"from pipelines.extract_graph_hybrid import extract_graph_hybrid\n"
"print('imports OK')"
))
cells.append(_md(
"## 2. Texto de entrada (castellano)\n\n"
"Tres parrafos sobre el sector empresarial espanol — directivos, sedes, acuerdos — con "
"entidades de tres tipos (Person, Organization, Location) y relaciones evidentes "
"(presidencias, sedes, acuerdos)."
))
cells.append(_code(
f"TEXTO = {SPANISH_TEXT!r}\n"
"print(TEXTO)\n"
"print()\n"
"print(f'longitud: {len(TEXTO)} chars ~{len(TEXTO.split())} tokens')"
))
cells.append(_md(
"## 3. Carga de modelos\n\n"
"Warm load (~8s cada uno) — modelos cacheados en `~/.cache/huggingface/`."
))
cells.append(_code(
"import time\n"
"t0 = time.time(); gliner = gliner_load_model(); print(f'GLiNER {time.time()-t0:.1f}s')\n"
"t0 = time.time(); glirel = glirel_load_model(); print(f'GLiREL {time.time()-t0:.1f}s')"
))
cells.append(_md(
"## 4. Pipeline `extract_graph_hybrid` — dos pasadas\n\n"
"El threshold del notebook 01 (`0.15`) se calibro mirando la _distribucion_ de "
"scores (max ~0.21 en EN, ~0.17 en ES). Pero **GLiREL evalua TODOS los pares "
"ordenados de entidades para CADA label**: con 15 entidades y 8 labels son "
"15×14×8 = 1680 candidatos. Aunque pocos pasan, los que pasan son una mezcla "
"de aciertos y plausibles-pero-falsos.\n\n"
"Vamos a hacer **dos pasadas** sobre el mismo texto: `0.15` (recall, ruidoso) y "
"`0.30` (precision, limpio). El notebook 01 solo midio scores agregados — esta "
"celda completa la calibracion mirando _calidad semantica_ del output."
))
cells.append(_code(
"entity_schema = [\n"
" {'type_ref': 'Person', 'label': 'person'},\n"
" {'type_ref': 'Organization', 'label': 'organization'},\n"
" {'type_ref': 'Location', 'label': 'location'},\n"
"]\n"
"relation_types = [\n"
" 'works_at', 'located_in', 'appointed_as', 'headquartered_in',\n"
" 'ceo_of', 'president_of', 'agreement_with', 'met_with',\n"
"]\n"
"\n"
"def run(threshold):\n"
" return extract_graph_hybrid(\n"
" chunks=[TEXTO],\n"
" entity_schema=entity_schema,\n"
" relation_types=relation_types,\n"
" gliner_model=gliner,\n"
" glirel_model=glirel,\n"
" llm_chat_json=None,\n"
" confidence_threshold=threshold,\n"
" )\n"
"\n"
"ents_recall, rels_recall = run(0.15)\n"
"ents_precision, rels_precision = run(0.30)\n"
"print(f'recall (t=0.15): {len(ents_recall):2d} ents {len(rels_recall):2d} rels')\n"
"print(f'precision (t=0.30): {len(ents_precision):2d} ents {len(rels_precision):2d} rels')\n"
"\n"
"# Trabajamos a partir de aqui con 'precision' como base\n"
"ents, rels = ents_precision, rels_precision"
))
cells.append(_md("### 4.1 Tabla de entidades"))
cells.append(_code(
"df_ents = pd.DataFrame([\n"
" {'name': e.name, 'type': e.type_ref, 'confidence': round(e.confidence, 3),\n"
" 'chunks': e.source_chunk_indices, 'merged_from': e.merged_from}\n"
" for e in ents\n"
"]).sort_values(['type','confidence'], ascending=[True, False])\n"
"df_ents"
))
cells.append(_md("### 4.2 Tabla de relaciones"))
cells.append(_code(
"df_rels = pd.DataFrame([\n"
" {'from': r.from_name, 'kind': r.relation_type, 'to': r.to_name,\n"
" 'confidence': round(r.confidence, 3), 'chunk': r.source_chunk_index}\n"
" for r in rels\n"
"]).sort_values('confidence', ascending=False)\n"
"df_rels"
))
cells.append(_md(
"## 5. Visualizacion comparativa — recall vs precision\n\n"
"Dos grafos, mismo texto, distinto threshold. Nodos coloreados por tipo, aristas "
"etiquetadas con `relation_type`, layout fuerza-dirigido. Es el mismo render que "
"el panel del `graph_explorer` haria tras un _Apply selected_, pero aqui en linea "
"para validar visualmente la calibracion."
))
cells.append(_code(
"TYPE_COLOR = {'Person': '#5DA5DA', 'Organization': '#F17CB0', 'Location': '#60BD68'}\n"
"\n"
"def draw(ax, ents, rels, title):\n"
" G = nx.DiGraph()\n"
" for e in ents:\n"
" G.add_node(e.name, type=e.type_ref, confidence=e.confidence)\n"
" for r in rels:\n"
" G.add_edge(r.from_name, r.to_name, kind=r.relation_type, confidence=r.confidence)\n"
" pos = nx.spring_layout(G, k=2.2, iterations=80, seed=42)\n"
" node_colors = [TYPE_COLOR.get(G.nodes[n].get('type'), '#bbb') for n in G.nodes]\n"
" nx.draw_networkx_nodes(G, pos, node_color=node_colors, node_size=1900,\n"
" edgecolors='#333', linewidths=1.4, ax=ax)\n"
" nx.draw_networkx_labels(G, pos, font_size=8, font_weight='bold', ax=ax)\n"
" nx.draw_networkx_edges(G, pos, edge_color='#888', arrows=True, arrowsize=14,\n"
" width=1.2, alpha=0.65, ax=ax,\n"
" connectionstyle='arc3,rad=0.08')\n"
" edge_labels = {(u, v): d['kind'] for u, v, d in G.edges(data=True)}\n"
" nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, font_size=6.5,\n"
" font_color='#333', label_pos=0.5,\n"
" bbox=dict(boxstyle='round,pad=0.1', fc='white', ec='none', alpha=0.8),\n"
" ax=ax)\n"
" ax.set_title(f'{title}: {G.number_of_nodes()} ents, {G.number_of_edges()} rels', fontsize=11)\n"
" ax.axis('off')\n"
"\n"
"fig, axes = plt.subplots(1, 2, figsize=(20, 9))\n"
"draw(axes[0], ents_recall, rels_recall, 't=0.15 (recall)')\n"
"draw(axes[1], ents_precision, rels_precision, 't=0.30 (precision)')\n"
"from matplotlib.patches import Patch\n"
"legend = [Patch(facecolor=c, edgecolor='#333', label=t) for t, c in TYPE_COLOR.items()]\n"
"axes[0].legend(handles=legend, loc='upper left', frameon=True, fontsize=10)\n"
"plt.tight_layout(); plt.show()"
))
cells.append(_md("### 5.1 Solo el grafo de precision (vista limpia)"))
cells.append(_code(
"fig, ax = plt.subplots(figsize=(13, 9))\n"
"draw(ax, ents_precision, rels_precision, 'Grafo final (t=0.30)')\n"
"from matplotlib.patches import Patch\n"
"legend = [Patch(facecolor=c, edgecolor='#333', label=t) for t, c in TYPE_COLOR.items()]\n"
"ax.legend(handles=legend, loc='upper left', frameon=True, fontsize=10)\n"
"plt.tight_layout(); plt.show()"
))
cells.append(_md(
"## 6. Lectura empirica — el hallazgo incomodo\n\n"
"**GLiNER funciona muy bien:** 15 entidades nucleo con confianza 0.92-0.98, en castellano, con labels en ingles. Sin asteriscos.\n\n"
"**GLiREL no funciona bien en este dominio.** No es un problema de threshold — es de fondo:\n\n"
"### Falsos positivos con score alto a `t=0.15`\n\n"
"Con 51 relaciones emitidas, la mayoria son espurias. Ejemplos reales del output:\n\n"
"| Score | from | kind | to | Realidad |\n"
"|---|---|---|---|---|\n"
"| 0.339 | Ignacio Galan | president_of | Jose Maria Alvarez-Pallete | **Falso.** Galan preside Iberdrola; Alvarez-Pallete preside Telefonica. No tienen relacion entre si. |\n"
"| 0.292 | Carlos Torres | president_of | Jose Maria Alvarez-Pallete | **Falso.** Torres preside BBVA. |\n"
"| 0.253 | Madrid | president_of | Jose Maria Alvarez-Pallete | **Sin sentido.** Una `Location` no preside a una `Person`. |\n"
"| 0.218 | Madrid | located_in | Inditex | **Invertido.** Inditex esta en Arteixo, no Madrid esta en Inditex. |\n\n"
"### Y al subir el threshold no mejora\n\n"
"A `t=0.30` (precision mode), solo sobrevive **1 relacion**: la primera de la tabla — que **tambien es falsa**. GLiREL ha aprendido que dos `Person` cerca de la palabra _presidente_ disparan `president_of` con confianza alta, sin importar la sintaxis ni la direccion.\n\n"
"### Por que pasa esto\n\n"
"1. **GLiREL evalua todos los pares ordenados × cada label.** Con 15 ents y 8 labels son 15×14×8 = **1680 candidatos**. Incluso con error <1% por candidato, el output a threshold permisivo es ruidoso.\n"
"2. **El modelo es atencional, no logico.** Aprende patrones de coocurrencia, no semantica. Por eso `Madrid president_of Persona` recibe score positivo cuando ambos aparecen cerca del verbo.\n"
"3. **`jackboyla/glirel-large-v0` esta entrenado mayoritariamente en ingles.** El gap EN/ES del notebook 01 (max 0.23 vs 0.17) es la punta del iceberg — la calidad semantica tambien cae.\n\n"
"### Que toca cambiar en el pipeline\n\n"
"1. **No usar GLiREL como decisor final** en castellano. Usarlo como _candidate generator_ y validar con LLM. El pipeline `extract_graph_hybrid` ya admite `llm_chat_json` para fallback de entidades — habria que extender el flujo a las relaciones (issue nuevo).\n"
"2. **Si no hay LLM disponible**, mejor emitir solo top-N por score (ej: top-3 relaciones globales) que filtrar por threshold global. El panel deja al humano elegir.\n"
"3. **El issue `0041-split-confidence-thresholds`** sigue siendo valido (separar entity y relation thresholds), pero ahora sabemos que el problema mas grave **NO es el threshold sino la calidad del modelo en este dominio**.\n"
"4. **Para OSINT/narrativa en EN**, GLiREL podria funcionar mejor (notebook 01 mostro scores ~25% mas altos en EN). No probado aqui.\n\n"
"### Decision provisional para el panel `paste_extract`\n\n"
"- **GLiNER (entidades): habilitado por defecto.** Funciona muy bien.\n"
"- **GLiREL (relaciones): deshabilitado por defecto en castellano** o, alternativamente, mostrar siempre con un banner explicando que las relaciones son sugerencias y deben validarse antes de _Apply_.\n"
"- **Issue nuevo:** integrar LLM como validator semantico de candidatos GLiREL antes de mostrar al usuario.\n\n"
"**Para iterar sobre tu propio texto:** edita la celda 5 (`TEXTO = ...`) y re-ejecuta desde la celda 7. Los modelos quedan cacheados en RAM."
))
nb = nbf.v4.new_notebook()
nb.cells = cells
nb.metadata = {
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
"language_info": {"name": "python"},
}
NB_PATH.parent.mkdir(parents=True, exist_ok=True)
nbf.write(nb, NB_PATH)
print(f"[done] {NB_PATH} cells={len(cells)}")
if __name__ == "__main__":
build()