"""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()