b8c760d004
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
263 lines
14 KiB
Python
263 lines
14 KiB
Python
"""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()
|