From 3ef1f45f374b79fa4e35fd9781be4f4ba59f6390 Mon Sep 17 00:00:00 2001 From: vardhjain Date: Thu, 11 Jun 2026 17:32:32 -0400 Subject: [PATCH 01/23] Refactor notebooks for Colab GPU compatibility and fair comparison - Both GraphRAG.ipynb and Plain_RAG.ipynb are now fully self-contained (no shared_utils import dependency) with all shared constants, prompts, FuzzyEvaluator, Evaluator, and call_ollama defined inline identically - GraphRAG: credentials via Colab Secrets (ARANGO_PASS) with env var fallback - Plain_RAG: GPU-aware FAISS with faiss-gpu-cu12 swap instruction - Add Comparison.ipynb for side-by-side accuracy/F1/latency/confusion charts - Add shared_utils.py for local script runs - Add run_graphrag.py, run_plainrag.py, run_comparison.py standalone scripts --- Comparison.ipynb | 335 ++ Data_Ingestion_KG.ipynb | 775 ++-- GraphRAG.ipynb | 1120 ++--- Plain_RAG/Plain_RAG.ipynb | 9209 ++----------------------------------- run_comparison.py | 127 + run_graphrag.py | 242 + run_plainrag.py | 162 + shared_utils.py | 153 + 8 files changed, 2140 insertions(+), 9983 deletions(-) create mode 100644 Comparison.ipynb create mode 100644 run_comparison.py create mode 100644 run_graphrag.py create mode 100644 run_plainrag.py create mode 100644 shared_utils.py diff --git a/Comparison.ipynb b/Comparison.ipynb new file mode 100644 index 0000000..cd81c75 --- /dev/null +++ b/Comparison.ipynb @@ -0,0 +1,335 @@ +{ + "nbformat": 4, + "nbformat_minor": 5, + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "cells": [ + { + "cell_type": "markdown", + "id": "md-title", + "metadata": {}, + "source": [ + "# GraphRAG vs Plain RAG โ€” Head-to-Head Comparison\n", + "\n", + "Run this notebook after both `GraphRAG.ipynb` and `Plain_RAG/Plain_RAG.ipynb`\n", + "have completed their benchmarks and saved results to the `results/` directory.\n", + "\n", + "**What is held constant (controlled variables):**\n", + "- Embedding model: `all-MiniLM-L6-v2`\n", + "- LLM: `deepseek-r1:8b` via Ollama\n", + "- Final retrieved documents fed to LLM: top-3\n", + "- System prompt (word-for-word identical)\n", + "- Answer extraction (`FuzzyEvaluator`)\n", + "- Evaluation dataset: `pqa_labeled` (same N samples, same order)\n", + "\n", + "**What differs (the independent variable):**\n", + "- **GraphRAG**: ArangoDB + wide search (top-75) + CrossEncoder reranking + graph expansion\n", + "- **Plain RAG**: FAISS + direct top-3 retrieval + concatenated chunk text" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-imports", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import os\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "import matplotlib.gridspec as gridspec\n", + "import seaborn as sns\n", + "from sklearn.metrics import (\n", + " accuracy_score, classification_report, confusion_matrix, f1_score\n", + ")\n", + "\n", + "RESULTS_DIR = 'results'\n", + "GRAPHRAG_FILE = os.path.join(RESULTS_DIR, 'graphrag_results.json')\n", + "PLAINRAG_FILE = os.path.join(RESULTS_DIR, 'plainrag_results.json')\n", + "LABELS = ['yes', 'no', 'maybe']\n", + "\n", + "for path in [GRAPHRAG_FILE, PLAINRAG_FILE]:\n", + " if not os.path.exists(path):\n", + " raise FileNotFoundError(\n", + " f'{path} not found. Run the corresponding notebook first.'\n", + " )\n", + "\n", + "with open(GRAPHRAG_FILE) as f:\n", + " gr = json.load(f)\n", + "with open(PLAINRAG_FILE) as f:\n", + " pr = json.load(f)\n", + "\n", + "print(f\"GraphRAG : {gr['samples']} samples, accuracy={gr['accuracy']:.2%}, avg_latency={gr['avg_latency']:.1f}s\")\n", + "print(f\"Plain RAG : {pr['samples']} samples, accuracy={pr['accuracy']:.2%}, avg_latency={pr['avg_latency']:.1f}s\")" + ] + }, + { + "cell_type": "markdown", + "id": "md-summary", + "metadata": {}, + "source": [ + "## Summary metrics" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-summary", + "metadata": {}, + "outputs": [], + "source": [ + "models = [gr['model'], pr['model']]\n", + "accs = [gr['accuracy'] * 100, pr['accuracy'] * 100]\n", + "lats = [gr['avg_latency'], pr['avg_latency']]\n", + "\n", + "gr_f1 = f1_score(gr['y_true'], gr['y_pred'], labels=LABELS, average='macro', zero_division=0)\n", + "pr_f1 = f1_score(pr['y_true'], pr['y_pred'], labels=LABELS, average='macro', zero_division=0)\n", + "f1s = [gr_f1 * 100, pr_f1 * 100]\n", + "\n", + "summary = pd.DataFrame({\n", + " 'Model': models,\n", + " 'Accuracy (%)': [round(a, 2) for a in accs],\n", + " 'Macro F1 (%)': [round(f, 2) for f in f1s],\n", + " 'Avg Latency (s)': [round(l, 2) for l in lats],\n", + " 'Samples': [gr['samples'], pr['samples']],\n", + "})\n", + "summary = summary.set_index('Model')\n", + "print(summary.to_string())" + ] + }, + { + "cell_type": "markdown", + "id": "md-accuracy-latency", + "metadata": {}, + "source": [ + "## Accuracy & Latency comparison" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-acc-lat", + "metadata": {}, + "outputs": [], + "source": [ + "colours = ['#2196F3', '#FF9800']\n", + "fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n", + "\n", + "# Accuracy\n", + "bars = axes[0].bar(models, accs, color=colours, width=0.4, edgecolor='white')\n", + "axes[0].set_ylim(0, 100)\n", + "axes[0].set_ylabel('Accuracy (%)')\n", + "axes[0].set_title('Accuracy')\n", + "for bar, val in zip(bars, accs):\n", + " axes[0].text(\n", + " bar.get_x() + bar.get_width() / 2, val + 1.5,\n", + " f'{val:.1f}%', ha='center', fontweight='bold'\n", + " )\n", + "\n", + "# Macro F1\n", + "bars2 = axes[1].bar(models, f1s, color=colours, width=0.4, edgecolor='white')\n", + "axes[1].set_ylim(0, 100)\n", + "axes[1].set_ylabel('Macro F1 (%)')\n", + "axes[1].set_title('Macro F1 Score')\n", + "for bar, val in zip(bars2, f1s):\n", + " axes[1].text(\n", + " bar.get_x() + bar.get_width() / 2, val + 1.5,\n", + " f'{val:.1f}%', ha='center', fontweight='bold'\n", + " )\n", + "\n", + "# Latency\n", + "bars3 = axes[2].bar(models, lats, color=colours, width=0.4, edgecolor='white')\n", + "axes[2].set_ylabel('Avg latency (s / query)')\n", + "axes[2].set_title('Latency')\n", + "for bar, val in zip(bars3, lats):\n", + " axes[2].text(\n", + " bar.get_x() + bar.get_width() / 2, val * 1.03,\n", + " f'{val:.1f}s', ha='center', fontweight='bold'\n", + " )\n", + "\n", + "plt.suptitle(\n", + " f'GraphRAG vs Plain RAG (n={gr[\"samples\"]} samples each)',\n", + " fontsize=14, fontweight='bold', y=1.02\n", + ")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "md-confusion", + "metadata": {}, + "source": [ + "## Confusion matrices" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-confusion", + "metadata": {}, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(1, 2, figsize=(13, 5))\n", + "\n", + "for ax, res, colour in zip(axes, [gr, pr], ['Blues', 'Oranges']):\n", + " cm = confusion_matrix(res['y_true'], res['y_pred'], labels=LABELS)\n", + " sns.heatmap(cm, annot=True, fmt='d', cmap=colour,\n", + " xticklabels=LABELS, yticklabels=LABELS, ax=ax)\n", + " ax.set_xlabel('Predicted')\n", + " ax.set_ylabel('Actual')\n", + " ax.set_title(res['model'] + f\" (acc={res['accuracy']:.2%})\")\n", + "\n", + "plt.suptitle('Confusion Matrices', fontsize=13, fontweight='bold')\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "md-per-class", + "metadata": {}, + "source": [ + "## Per-class F1 comparison" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-per-class", + "metadata": {}, + "outputs": [], + "source": [ + "def per_class_f1(res):\n", + " f1s = f1_score(res['y_true'], res['y_pred'],\n", + " labels=LABELS, average=None, zero_division=0)\n", + " return dict(zip(LABELS, f1s))\n", + "\n", + "gr_f1s = per_class_f1(gr)\n", + "pr_f1s = per_class_f1(pr)\n", + "\n", + "x = np.arange(len(LABELS))\n", + "width = 0.35\n", + "\n", + "fig, ax = plt.subplots(figsize=(9, 5))\n", + "bars1 = ax.bar(x - width / 2, [gr_f1s[l] * 100 for l in LABELS],\n", + " width, label=gr['model'], color='#2196F3', edgecolor='white')\n", + "bars2 = ax.bar(x + width / 2, [pr_f1s[l] * 100 for l in LABELS],\n", + " width, label=pr['model'], color='#FF9800', edgecolor='white')\n", + "\n", + "ax.set_xticks(x)\n", + "ax.set_xticklabels(LABELS)\n", + "ax.set_ylabel('F1 Score (%)')\n", + "ax.set_ylim(0, 100)\n", + "ax.set_title('Per-class F1 Score')\n", + "ax.legend()\n", + "\n", + "for bar in bars1:\n", + " h = bar.get_height()\n", + " ax.text(bar.get_x() + bar.get_width() / 2, h + 1, f'{h:.1f}', ha='center', fontsize=9)\n", + "for bar in bars2:\n", + " h = bar.get_height()\n", + " ax.text(bar.get_x() + bar.get_width() / 2, h + 1, f'{h:.1f}', ha='center', fontsize=9)\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "md-dist", + "metadata": {}, + "source": [ + "## Prediction distribution" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-dist", + "metadata": {}, + "outputs": [], + "source": [ + "def count_preds(res):\n", + " s = pd.Series(res['y_pred'])\n", + " return s.value_counts().reindex(LABELS, fill_value=0)\n", + "\n", + "gr_counts = count_preds(gr)\n", + "pr_counts = count_preds(pr)\n", + "gt_counts = pd.Series(gr['y_true']).value_counts().reindex(LABELS, fill_value=0)\n", + "\n", + "x = np.arange(len(LABELS))\n", + "width = 0.25\n", + "\n", + "fig, ax = plt.subplots(figsize=(10, 5))\n", + "ax.bar(x - width, gt_counts, width, label='Ground Truth', color='#4CAF50', edgecolor='white')\n", + "ax.bar(x, gr_counts, width, label=gr['model'], color='#2196F3', edgecolor='white')\n", + "ax.bar(x + width, pr_counts, width, label=pr['model'], color='#FF9800', edgecolor='white')\n", + "\n", + "ax.set_xticks(x)\n", + "ax.set_xticklabels(LABELS)\n", + "ax.set_ylabel('Count')\n", + "ax.set_title('Prediction Distribution vs Ground Truth')\n", + "ax.legend()\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "md-verdict", + "metadata": {}, + "source": [ + "## Verdict" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-verdict", + "metadata": {}, + "outputs": [], + "source": [ + "acc_delta = (gr['accuracy'] - pr['accuracy']) * 100\n", + "f1_delta = (gr_f1 - pr_f1) * 100\n", + "lat_delta = gr['avg_latency'] - pr['avg_latency']\n", + "\n", + "winner = gr['model'] if acc_delta >= 0 else pr['model']\n", + "\n", + "print('=' * 55)\n", + "print(f' Winner by accuracy : {winner}')\n", + "print(f' Accuracy delta : {acc_delta:+.2f} pp (GraphRAG - Plain RAG)')\n", + "print(f' Macro F1 delta : {f1_delta:+.2f} pp')\n", + "print(f' Latency delta : {lat_delta:+.2f}s (GraphRAG - Plain RAG)')\n", + "print('=' * 55)\n", + "print()\n", + "if acc_delta > 0:\n", + " print(\n", + " f'GraphRAG outperforms Plain RAG by {acc_delta:.1f} percentage points, '\n", + " 'confirming that the graph-structured context (full abstract reconstruction '\n", + " 'via AQL traversal) and CrossEncoder reranking provide measurable improvements '\n", + " 'over flat vector retrieval โ€” even with the same embedding model and LLM.'\n", + " )\n", + "elif acc_delta < 0:\n", + " print(\n", + " f'Plain RAG outperforms GraphRAG by {abs(acc_delta):.1f} percentage points '\n", + " 'on this sample. This may indicate that graph expansion introduces noise for '\n", + " 'some query types, or that the sample size is insufficient for a conclusive result.'\n", + " )\n", + "else:\n", + " print('Both models perform identically on this sample.')" + ] + } + ] +} diff --git a/Data_Ingestion_KG.ipynb b/Data_Ingestion_KG.ipynb index 5e0c0be..222a43e 100644 --- a/Data_Ingestion_KG.ipynb +++ b/Data_Ingestion_KG.ipynb @@ -1,468 +1,311 @@ { - "nbformat": 4, - "nbformat_minor": 0, - "metadata": { - "colab": { - "provenance": [] - }, - "kernelspec": { - "name": "python3", - "display_name": "Python 3" - }, - "language_info": { - "name": "python" - } + "nbformat": 4, + "nbformat_minor": 5, + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" }, - "cells": [ - { - "cell_type": "code", - "source": [ - "!pip install python-arango sentence-transformers datasets tqdm" - ], - "metadata": { - "id": "rUGLgHO2I-_Q" - }, - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "code", - "source": [ - "import time\n", - "from arango import ArangoClient\n", - "from sentence_transformers import SentenceTransformer\n", - "from datasets import load_dataset\n", - "from tqdm import tqdm" - ], - "metadata": { - "id": "Nyu1zWSUJKbO" - }, - "execution_count": 2, - "outputs": [] - }, - { - "cell_type": "code", - "source": [ - "ARANGO_CONFIG = {\n", - " \"hosts\": \"https://bfc25a0e3c74.arangodb.cloud:8529\",\n", - " \"username\": \"root\",\n", - " \"password\": \"VnicTWKeXaDasFNfmCfU\",\n", - " \"db_name\": \"pubmed_graph\",\n", - " \"chunk_col\": \"Chunks\",\n", - " \"context_edge\": \"HAS_CONTEXT\",\n", - " \"mention_edge\": \"MENTIONS\"\n", - "}" - ], - "metadata": { - "id": "KAdtBe5TG5E5" - }, - "execution_count": 3, - "outputs": [] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "dW9J5OorEnsz" - }, - "outputs": [], - "source": [ - "# @title ๐Ÿš€ GraphRAG Builder (Fixed & Complete)\n", - "# This script installs dependencies, connects to ArangoDB, sets up the schema,\n", - "# and ingests the PubMedQA dataset into the graph.\n", - "\n", - "# --- MANUAL CONFIGURATION ---\n", - "# Paste your details directly here to avoid input errors:\n", - "\n", - "# 1. The URL must start with https:// and usually ends with :8529\n", - "ARANGO_URL = \"https://bfc25a0e3c74.arangodb.cloud:8529\"\n", - "\n", - "# 2. The Username is almost always 'root'\n", - "ARANGO_USER = \"root\"\n", - "\n", - "# 3. Paste the password you copied from the 'Users' tab\n", - "ARANGO_PASS = \"VnicTWKeXaDasFNfmCfU\"\n", - "\n", - "# Database Name\n", - "DB_NAME = \"pubmed_graph\"\n", - "\n", - "# --- CONNECT ---\n", - "print(f\"Connecting to {ARANGO_URL}...\")\n", - "client = ArangoClient(hosts=ARANGO_URL)\n", - "sys_db = client.db(\"_system\", username=ARANGO_USER, password=ARANGO_PASS)\n", - "\n", - "# Create/Connect to specific database\n", - "if not sys_db.has_database(DB_NAME):\n", - " sys_db.create_database(DB_NAME)\n", - " print(f\"Created database: {DB_NAME}\")\n", - "else:\n", - " print(f\"Using existing database: {DB_NAME}\")\n", - "\n", - "db = client.db(DB_NAME, username=ARANGO_USER, password=ARANGO_PASS)\n", - "print(\"โœ… Connected Successfully!\")\n", - "\n", - "# --- SCHEMA SETUP ---\n", - "print(\"\\nCreating Graph Schema...\")\n", - "\n", - "# 1. Define Node Collections\n", - "node_collections = [\"Papers\", \"Chunks\", \"Concepts\"]\n", - "for col in node_collections:\n", - " if not db.has_collection(col):\n", - " db.create_collection(col)\n", - " print(f\" - Created Node Collection: {col}\")\n", - "\n", - "# 2. Define Edge Collections\n", - "edge_collections = [\"HAS_CONTEXT\", \"MENTIONS\"]\n", - "for col in edge_collections:\n", - " if not db.has_collection(col):\n", - " db.create_collection(col, edge=True)\n", - " print(f\" - Created Edge Collection: {col}\")\n", - "\n", - "# 3. Create ArangoSearch View (Fallback for Vector Search)\n", - "# FIXED: The 'vector' index type is experimental in your version.\n", - "# We use an ArangoSearch View instead, which is robust and works on all versions.\n", - "view_name = \"pubmed_view\"\n", - "\n", - "# FIXED: Use db.views() list comprehension to check existence instead of .has_view()\n", - "existing_views = [v[\"name\"] for v in db.views()]\n", - "\n", - "if view_name not in existing_views:\n", - " # FIXED: Use dedicated method 'create_arangosearch_view' to avoid TypeError on 'type' arg\n", - " db.create_arangosearch_view(\n", - " name=view_name,\n", - " properties={\n", - " \"links\": {\n", - " \"Chunks\": {\n", - " \"fields\": {\n", - " \"embedding\": {\n", - " \"analyzers\": [\"identity\"] # Needed for vector operations\n", - " },\n", - " \"text\": {\n", - " \"analyzers\": [\"text_en\"] # Useful for keyword search\n", - " }\n", - " }\n", - " }\n", - " }\n", - " }\n", - " )\n", - " print(f\" - Created ArangoSearch View: {view_name}\")\n", - "else:\n", - " print(f\" - ArangoSearch View '{view_name}' already exists.\")\n", - "\n", - "print(\"\\nโœ… Database Configured Successfully!\")\n", - "\n", - "# --- LOAD DATA & MODEL ---\n", - "print(\"\\nLoading Embedding Model & Dataset...\")\n", - "\n", - "# Load Model (Runs on GPU if available in Colab)\n", - "# We use all-MiniLM-L6-v2 for speed and good performance\n", - "model = SentenceTransformer('all-MiniLM-L6-v2')\n", - "\n", - "# Load Dataset (Standard download to avoid 429 Rate Limits)\n", - "# REMOVED: streaming=True to prevent \"Too Many Requests\" error\n", - "ds = load_dataset(\"qiaojin/PubMedQA\", \"pqa_unlabeled\", split=\"train\")\n", - "\n", - "print(\"โœ… Model and Data ready.\")\n", - "\n", - "# --- PROCESSING LOOP ---\n", - "# This loop processes papers, chunks them, embeds them, and inserts into ArangoDB.\n", - "\n", - "BATCH_SIZE = 50 # Number of papers to process before sending to DB (smaller batch for safety)\n", - "LIMIT_PAPERS = None # Limit for this run to ensure it finishes quickly (Set to None for full dataset)\n", - "\n", - "papers_batch = []\n", - "chunks_batch = []\n", - "concepts_batch = []\n", - "edges_batch = []\n", - "\n", - "print(f\"\\n๐Ÿš€ Starting Ingestion (Limit: {LIMIT_PAPERS} papers)...\")\n", - "start_time = time.time()\n", - "\n", - "count = 0\n", - "\n", - "for row in tqdm(ds, total=LIMIT_PAPERS):\n", - " if LIMIT_PAPERS and count >= LIMIT_PAPERS:\n", - " break\n", - "\n", - " pubid = row['pubid']\n", - " question = row['question']\n", - " long_answer = row['long_answer']\n", - "\n", - " # 1. Prepare Paper Node\n", - " paper_key = str(pubid)\n", - " papers_batch.append({\n", - " \"_key\": paper_key,\n", - " \"title\": question,\n", - " \"answer\": long_answer\n", - " })\n", - "\n", - " # 2. Process Concepts (MeSH Terms)\n", - " mesh_terms = row.get('context', {}).get('meshes', [])\n", - " for mesh in mesh_terms:\n", - " # Sanitize key (Arango keys cannot contain spaces/special chars easily, so we hash or simplify)\n", - " # Here we just remove non-alphanumeric for simplicity\n", - " mesh_key = \"\".join(x for x in mesh if x.isalnum())\n", - " if not mesh_key: continue\n", - "\n", - " # Add Concept Node\n", - " concepts_batch.append({\n", - " \"_key\": mesh_key,\n", - " \"name\": mesh\n", - " })\n", - "\n", - " # Link Paper -> Concept\n", - " edges_batch.append({\n", - " \"_collection\": \"MENTIONS\",\n", - " \"_from\": f\"Papers/{paper_key}\",\n", - " \"_to\": f\"Concepts/{mesh_key}\"\n", - " })\n", - "\n", - " # 3. Process Contexts (Chunks)\n", - " contexts = row.get('context', {}).get('contexts', [])\n", - " labels = row.get('context', {}).get('labels', [])\n", - "\n", - " if contexts:\n", - " # Embed all chunks for this paper at once\n", - " embeddings = model.encode(contexts)\n", - "\n", - " for idx, (text, emb) in enumerate(zip(contexts, embeddings)):\n", - " chunk_key = f\"{paper_key}_{idx}\"\n", - "\n", - " # Add Chunk Node\n", - " chunks_batch.append({\n", - " \"_key\": chunk_key,\n", - " \"text\": text,\n", - " \"label\": labels[idx] if idx < len(labels) else \"context\",\n", - " \"embedding\": emb.tolist() # Convert numpy array to list for JSON\n", - " })\n", - "\n", - " # Link Paper -> Chunk\n", - " edges_batch.append({\n", - " \"_collection\": \"HAS_CONTEXT\",\n", - " \"_from\": f\"Papers/{paper_key}\",\n", - " \"_to\": f\"Chunks/{chunk_key}\"\n", - " })\n", - "\n", - " count += 1\n", - "\n", - " # --- BATCH INSERTION ---\n", - " if count % BATCH_SIZE == 0:\n", - " # Insert Papers\n", - " if papers_batch:\n", - " db.collection(\"Papers\").import_bulk(papers_batch, on_duplicate=\"ignore\")\n", - " # Insert Concepts\n", - " if concepts_batch:\n", - " db.collection(\"Concepts\").import_bulk(concepts_batch, on_duplicate=\"ignore\")\n", - " # Insert Chunks\n", - " if chunks_batch:\n", - " db.collection(\"Chunks\").import_bulk(chunks_batch, on_duplicate=\"ignore\")\n", - "\n", - " # Insert Edges (Must split by collection type for import_bulk)\n", - " mentions = [e for e in edges_batch if e[\"_collection\"] == \"MENTIONS\"]\n", - " contexts = [e for e in edges_batch if e[\"_collection\"] == \"HAS_CONTEXT\"]\n", - "\n", - " if mentions:\n", - " db.collection(\"MENTIONS\").import_bulk(mentions, on_duplicate=\"ignore\")\n", - " if contexts:\n", - " db.collection(\"HAS_CONTEXT\").import_bulk(contexts, on_duplicate=\"ignore\")\n", - "\n", - " # Reset batches\n", - " papers_batch = []\n", - " chunks_batch = []\n", - " concepts_batch = []\n", - " edges_batch = []\n", - "\n", - "# Final flush for remaining data\n", - "if papers_batch: db.collection(\"Papers\").import_bulk(papers_batch, on_duplicate=\"ignore\")\n", - "if concepts_batch: db.collection(\"Concepts\").import_bulk(concepts_batch, on_duplicate=\"ignore\")\n", - "if chunks_batch: db.collection(\"Chunks\").import_bulk(chunks_batch, on_duplicate=\"ignore\")\n", - "\n", - "mentions = [e for e in edges_batch if e[\"_collection\"] == \"MENTIONS\"]\n", - "contexts = [e for e in edges_batch if e[\"_collection\"] == \"HAS_CONTEXT\"]\n", - "if mentions: db.collection(\"MENTIONS\").import_bulk(mentions, on_duplicate=\"ignore\")\n", - "if contexts: db.collection(\"HAS_CONTEXT\").import_bulk(contexts, on_duplicate=\"ignore\")\n", - "\n", - "end_time = time.time()\n", - "print(f\"\\n๐ŸŽ‰ Finished! Processed {count} papers in {end_time - start_time:.2f} seconds.\")\n", - "print(f\"Go to your ArangoDB Dashboard to see the 'pubmed_graph' database.\")\n", - "print(f\"IMPORTANT: Use 'FOR doc IN pubmed_view' in your AQL queries!\")" - ] - }, - { - "cell_type": "code", - "source": [ - "# @title โž• Add PubMedQA \"Labeled\" Subset\n", - "# This script adds the 1,000 labeled papers to your existing graph.\n", - "\n", - "\n", - "# --- MANUAL CONFIGURATION ---\n", - "# 1. The URL must start with https:// and usually ends with :8529\n", - "ARANGO_URL = \"https://bfc25a0e3c74.arangodb.cloud:8529\"\n", - "# 2. The Username\n", - "ARANGO_USER = \"root\"\n", - "# 3. Paste the password you copied from the 'Users' tab\n", - "ARANGO_PASS = \"VnicTWKeXaDasFNfmCfU\"\n", - "# Database Name\n", - "DB_NAME = \"pubmed_graph\"\n", - "\n", - "# --- CONNECT ---\n", - "print(f\"Connecting to {ARANGO_URL}...\")\n", - "client = ArangoClient(hosts=ARANGO_URL)\n", - "db = client.db(DB_NAME, username=ARANGO_USER, password=ARANGO_PASS)\n", - "print(\"โœ… Connected to 'pubmed_graph'!\")\n", - "\n", - "# --- LOAD DATA & MODEL ---\n", - "print(\"\\nLoading 'pqa_labeled' dataset...\")\n", - "\n", - "# Load the LABELED subset this time\n", - "ds_labeled = load_dataset(\"qiaojin/PubMedQA\", \"pqa_labeled\", split=\"train\")\n", - "model = SentenceTransformer('all-MiniLM-L6-v2')\n", - "\n", - "print(f\"โœ… Loaded {len(ds_labeled)} labeled papers.\")\n", - "\n", - "# --- PROCESSING LOOP ---\n", - "BATCH_SIZE = 50\n", - "papers_batch = []\n", - "chunks_batch = []\n", - "concepts_batch = []\n", - "edges_batch = []\n", - "\n", - "print(\"\\n๐Ÿš€ Starting Ingestion of Labeled Data...\")\n", - "start_time = time.time()\n", - "count = 0\n", - "\n", - "for row in tqdm(ds_labeled):\n", - " pubid = row['pubid']\n", - " question = row['question']\n", - " long_answer = row['long_answer']\n", - " final_decision = row.get('final_decision', None) # Unique to labeled set\n", - "\n", - " # 1. Prepare Paper Node (With extra 'final_decision' field)\n", - " paper_key = str(pubid)\n", - " papers_batch.append({\n", - " \"_key\": paper_key,\n", - " \"title\": question,\n", - " \"answer\": long_answer,\n", - " \"decision\": final_decision, # Store 'yes', 'no', or 'maybe'\n", - " \"dataset\": \"labeled\" # Tag it so we know source\n", - " })\n", - "\n", - " # 2. Process Concepts (MeSH Terms)\n", - " mesh_terms = row.get('context', {}).get('meshes', [])\n", - " for mesh in mesh_terms:\n", - " mesh_key = \"\".join(x for x in mesh if x.isalnum())\n", - " if not mesh_key: continue\n", - "\n", - " concepts_batch.append({\n", - " \"_key\": mesh_key,\n", - " \"name\": mesh\n", - " })\n", - " edges_batch.append({\n", - " \"_collection\": \"MENTIONS\",\n", - " \"_from\": f\"Papers/{paper_key}\",\n", - " \"_to\": f\"Concepts/{mesh_key}\"\n", - " })\n", - "\n", - " # 3. Process Contexts (Chunks)\n", - " contexts = row.get('context', {}).get('contexts', [])\n", - " labels = row.get('context', {}).get('labels', [])\n", - "\n", - " if contexts:\n", - " embeddings = model.encode(contexts)\n", - " for idx, (text, emb) in enumerate(zip(contexts, embeddings)):\n", - " chunk_key = f\"{paper_key}_{idx}\"\n", - " chunks_batch.append({\n", - " \"_key\": chunk_key,\n", - " \"text\": text,\n", - " \"label\": labels[idx] if idx < len(labels) else \"context\",\n", - " \"embedding\": emb.tolist()\n", - " })\n", - " edges_batch.append({\n", - " \"_collection\": \"HAS_CONTEXT\",\n", - " \"_from\": f\"Papers/{paper_key}\",\n", - " \"_to\": f\"Chunks/{chunk_key}\"\n", - " })\n", - "\n", - " count += 1\n", - "\n", - " # --- BATCH INSERTION ---\n", - " if count % BATCH_SIZE == 0:\n", - " if papers_batch: db.collection(\"Papers\").import_bulk(papers_batch, on_duplicate=\"update\") # Update if exists\n", - " if concepts_batch: db.collection(\"Concepts\").import_bulk(concepts_batch, on_duplicate=\"ignore\")\n", - " if chunks_batch: db.collection(\"Chunks\").import_bulk(chunks_batch, on_duplicate=\"ignore\")\n", - "\n", - " mentions = [e for e in edges_batch if e[\"_collection\"] == \"MENTIONS\"]\n", - " contexts = [e for e in edges_batch if e[\"_collection\"] == \"HAS_CONTEXT\"]\n", - " if mentions: db.collection(\"MENTIONS\").import_bulk(mentions, on_duplicate=\"ignore\")\n", - " if contexts: db.collection(\"HAS_CONTEXT\").import_bulk(contexts, on_duplicate=\"ignore\")\n", - "\n", - " papers_batch = []\n", - " chunks_batch = []\n", - " concepts_batch = []\n", - " edges_batch = []\n", - "\n", - "# Final Flush\n", - "if papers_batch: db.collection(\"Papers\").import_bulk(papers_batch, on_duplicate=\"update\")\n", - "if concepts_batch: db.collection(\"Concepts\").import_bulk(concepts_batch, on_duplicate=\"ignore\")\n", - "if chunks_batch: db.collection(\"Chunks\").import_bulk(chunks_batch, on_duplicate=\"ignore\")\n", - "mentions = [e for e in edges_batch if e[\"_collection\"] == \"MENTIONS\"]\n", - "contexts = [e for e in edges_batch if e[\"_collection\"] == \"HAS_CONTEXT\"]\n", - "if mentions: db.collection(\"MENTIONS\").import_bulk(mentions, on_duplicate=\"ignore\")\n", - "if contexts: db.collection(\"HAS_CONTEXT\").import_bulk(contexts, on_duplicate=\"ignore\")\n", - "\n", - "end_time = time.time()\n", - "print(f\"\\n๐ŸŽ‰ Added {count} labeled papers in {end_time - start_time:.2f} seconds.\")" - ], - "metadata": { - "id": "q_dQy8L2Ew8r" - }, - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "code", - "source": [ - "# @title ๐Ÿงน Remove \"decision\" and \"dataset\" columns\n", - "# This script iterates through Papers and deletes the specific attributes.\n", - "\n", - "# 1. Define the AQL Query\n", - "# We filter for papers that actually have these fields to save processing time.\n", - "# Setting them to 'null' with 'keepNull: false' deletes the attribute entirely.\n", - "aql_clean_columns = \"\"\"\n", - "FOR p IN Papers\n", - " FILTER HAS(p, \"decision\") OR HAS(p, \"dataset\")\n", - "\n", - " UPDATE p WITH {\n", - " decision: null,\n", - " dataset: null\n", - " } IN Papers\n", - " OPTIONS { keepNull: false }\n", - "\"\"\"\n", - "\n", - "# 2. Execute\n", - "print(\"Removing 'decision' and 'dataset' columns...\")\n", - "cursor = db.aql.execute(aql_clean_columns)\n", - "\n", - "# 3. Verify\n", - "# Let's count if any remain\n", - "verification_query = \"\"\"\n", - "FOR p IN Papers\n", - " FILTER HAS(p, \"decision\")\n", - " COLLECT WITH COUNT INTO count\n", - " RETURN count\n", - "\"\"\"\n", - "count = list(db.aql.execute(verification_query))[0]\n", - "\n", - "if count == 0:\n", - " print(\"โœ… Success! Columns removed. All papers now have a uniform schema.\")\n", - "else:\n", - " print(f\"โš ๏ธ Something went wrong. {count} papers still have the decision column.\")" - ], - "metadata": { - "id": "olhs2y-2Eyww" - }, - "execution_count": null, - "outputs": [] - } - ] -} \ No newline at end of file + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "cells": [ + { + "cell_type": "markdown", + "id": "md-title", + "metadata": {}, + "source": [ + "# Knowledge Graph Construction โ€” Data Ingestion\n", + "\n", + "Builds the ArangoDB knowledge graph from PubMedQA. Run this notebook **once**\n", + "before using `GraphRAG.ipynb`.\n", + "\n", + "**Graph schema:**\n", + "- Nodes: `Papers`, `Chunks` (abstract segments), `Concepts` (MeSH terms)\n", + "- Edges: `HAS_CONTEXT` (Paper โ†’ Chunk), `MENTIONS` (Paper โ†’ Concept)\n", + "\n", + "**Two ingestion passes:**\n", + "1. `pqa_unlabeled` โ€” full corpus without ground-truth labels\n", + "2. `pqa_labeled` โ€” 1 000 papers with `final_decision` (yes / no / maybe)\n", + "\n", + "Set `ARANGO_HOST`, `ARANGO_USER`, `ARANGO_PASS` as environment variables\n", + "(or Colab secrets) before running." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-install", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install python-arango sentence-transformers datasets tqdm -q" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-imports", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import time\n", + "from arango import ArangoClient\n", + "from sentence_transformers import SentenceTransformer\n", + "from datasets import load_dataset\n", + "from tqdm import tqdm" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-config", + "metadata": {}, + "outputs": [], + "source": [ + "# โ”€โ”€ Credentials โ€” set via environment variables, never hardcode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "ARANGO_HOST = os.environ.get('ARANGO_HOST', 'https://bfc25a0e3c74.arangodb.cloud:8529')\n", + "ARANGO_USER = os.environ.get('ARANGO_USER', 'root')\n", + "ARANGO_PASS = os.environ.get('ARANGO_PASS', '') # required โ€” set before running\n", + "DB_NAME = os.environ.get('ARANGO_DB', 'pubmed_graph')\n", + "\n", + "# โ”€โ”€ Graph schema names โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "NODE_COLS = ['Papers', 'Chunks', 'Concepts']\n", + "EDGE_COLS = ['HAS_CONTEXT', 'MENTIONS']\n", + "VIEW_NAME = 'pubmed_view'\n", + "\n", + "# โ”€โ”€ Embedding โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "EMBEDDING_MODEL = 'sentence-transformers/all-MiniLM-L6-v2' # 384-dim; matches GraphRAG\n", + "BATCH_SIZE = 50\n", + "\n", + "if not ARANGO_PASS:\n", + " raise EnvironmentError(\n", + " 'Set the ARANGO_PASS environment variable before running this notebook.'\n", + " )\n", + "\n", + "print('Configuration loaded.')" + ] + }, + { + "cell_type": "markdown", + "id": "md-connect", + "metadata": {}, + "source": [ + "## 1. Connect and set up graph schema" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-connect", + "metadata": {}, + "outputs": [], + "source": [ + "client = ArangoClient(hosts=ARANGO_HOST)\n", + "sys_db = client.db('_system', username=ARANGO_USER, password=ARANGO_PASS)\n", + "\n", + "if not sys_db.has_database(DB_NAME):\n", + " sys_db.create_database(DB_NAME)\n", + " print(f'Created database: {DB_NAME}')\n", + "else:\n", + " print(f'Using existing database: {DB_NAME}')\n", + "\n", + "db = client.db(DB_NAME, username=ARANGO_USER, password=ARANGO_PASS)\n", + "\n", + "# Node collections\n", + "for col in NODE_COLS:\n", + " if not db.has_collection(col):\n", + " db.create_collection(col)\n", + " print(f' Created node collection: {col}')\n", + "\n", + "# Edge collections\n", + "for col in EDGE_COLS:\n", + " if not db.has_collection(col):\n", + " db.create_collection(col, edge=True)\n", + " print(f' Created edge collection: {col}')\n", + "\n", + "# ArangoSearch view for vector + keyword search\n", + "existing_views = [v['name'] for v in db.views()]\n", + "if VIEW_NAME not in existing_views:\n", + " db.create_arangosearch_view(\n", + " name=VIEW_NAME,\n", + " properties={\n", + " 'links': {\n", + " 'Chunks': {\n", + " 'fields': {\n", + " 'embedding': {'analyzers': ['identity']},\n", + " 'text': {'analyzers': ['text_en']},\n", + " }\n", + " }\n", + " }\n", + " },\n", + " )\n", + " print(f' Created ArangoSearch view: {VIEW_NAME}')\n", + "else:\n", + " print(f' ArangoSearch view already exists: {VIEW_NAME}')\n", + "\n", + "print('\\nSchema ready.')" + ] + }, + { + "cell_type": "markdown", + "id": "md-unlabeled", + "metadata": {}, + "source": [ + "## 2. Ingest `pqa_unlabeled`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-ingest-unlabeled", + "metadata": {}, + "outputs": [], + "source": [ + "def ingest_dataset(db, dataset, model, batch_size=BATCH_SIZE, on_duplicate_paper='ignore'):\n", + " \"\"\"\n", + " Iterates over a PubMedQA dataset and inserts Papers, Chunks, Concepts\n", + " and their edges in batches.\n", + " \"\"\"\n", + " papers_buf = []\n", + " chunks_buf = []\n", + " concepts_buf = []\n", + " has_ctx_buf = [] # HAS_CONTEXT edges\n", + " mentions_buf = [] # MENTIONS edges\n", + " count = 0\n", + "\n", + " def flush():\n", + " if papers_buf:\n", + " db.collection('Papers').import_bulk(papers_buf, on_duplicate=on_duplicate_paper)\n", + " if concepts_buf:\n", + " db.collection('Concepts').import_bulk(concepts_buf, on_duplicate='ignore')\n", + " if chunks_buf:\n", + " db.collection('Chunks').import_bulk(chunks_buf, on_duplicate='ignore')\n", + " if has_ctx_buf:\n", + " db.collection('HAS_CONTEXT').import_bulk(has_ctx_buf, on_duplicate='ignore')\n", + " if mentions_buf:\n", + " db.collection('MENTIONS').import_bulk(mentions_buf, on_duplicate='ignore')\n", + " papers_buf.clear()\n", + " chunks_buf.clear()\n", + " concepts_buf.clear()\n", + " has_ctx_buf.clear()\n", + " mentions_buf.clear()\n", + "\n", + " for row in tqdm(dataset):\n", + " paper_key = str(row['pubid'])\n", + "\n", + " # Paper node\n", + " paper_doc = {\n", + " '_key': paper_key,\n", + " 'title': row['question'],\n", + " 'answer': row['long_answer'],\n", + " }\n", + " if row.get('final_decision'):\n", + " paper_doc['final_decision'] = row['final_decision']\n", + " papers_buf.append(paper_doc)\n", + "\n", + " # Concept (MeSH) nodes + MENTIONS edges\n", + " for mesh in row.get('context', {}).get('meshes', []):\n", + " mesh_key = ''.join(c for c in mesh if c.isalnum())\n", + " if not mesh_key:\n", + " continue\n", + " concepts_buf.append({'_key': mesh_key, 'name': mesh})\n", + " mentions_buf.append({\n", + " '_from': 'Papers/' + paper_key,\n", + " '_to': 'Concepts/' + mesh_key,\n", + " })\n", + "\n", + " # Chunk nodes + HAS_CONTEXT edges\n", + " ctx_texts = row.get('context', {}).get('contexts', [])\n", + " ctx_labels = row.get('context', {}).get('labels', [])\n", + " if ctx_texts:\n", + " embeddings = model.encode(ctx_texts)\n", + " for idx, (text, emb) in enumerate(zip(ctx_texts, embeddings)):\n", + " chunk_key = paper_key + '_' + str(idx)\n", + " chunks_buf.append({\n", + " '_key': chunk_key,\n", + " 'text': text,\n", + " 'label': ctx_labels[idx] if idx < len(ctx_labels) else 'context',\n", + " 'embedding': emb.tolist(),\n", + " })\n", + " has_ctx_buf.append({\n", + " '_from': 'Papers/' + paper_key,\n", + " '_to': 'Chunks/' + chunk_key,\n", + " })\n", + "\n", + " count += 1\n", + " if count % batch_size == 0:\n", + " flush()\n", + "\n", + " flush() # final partial batch\n", + " return count\n", + "\n", + "\n", + "print('Loading model and pqa_unlabeled dataset...')\n", + "model = SentenceTransformer(EMBEDDING_MODEL)\n", + "ds_unlabeled = load_dataset('qiaojin/PubMedQA', 'pqa_unlabeled', split='train')\n", + "\n", + "print(f'Ingesting {len(ds_unlabeled):,} unlabeled papers...')\n", + "t0 = time.time()\n", + "count = ingest_dataset(db, ds_unlabeled, model, on_duplicate_paper='ignore')\n", + "print(f'Done. {count:,} papers in {time.time() - t0:.1f}s.')" + ] + }, + { + "cell_type": "markdown", + "id": "md-labeled", + "metadata": {}, + "source": [ + "## 3. Ingest `pqa_labeled`\n", + "\n", + "These 1 000 papers have ground-truth `final_decision` labels used for evaluation.\n", + "Papers already ingested from `pqa_unlabeled` are updated (not duplicated)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-ingest-labeled", + "metadata": {}, + "outputs": [], + "source": [ + "print('Loading pqa_labeled dataset...')\n", + "ds_labeled = load_dataset('qiaojin/PubMedQA', 'pqa_labeled', split='train')\n", + "\n", + "print(f'Ingesting {len(ds_labeled):,} labeled papers...')\n", + "t0 = time.time()\n", + "count = ingest_dataset(db, ds_labeled, model, on_duplicate_paper='update')\n", + "print(f'Done. {count:,} papers in {time.time() - t0:.1f}s.')" + ] + }, + { + "cell_type": "markdown", + "id": "md-verify", + "metadata": {}, + "source": [ + "## 4. Verify ingestion" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-verify", + "metadata": {}, + "outputs": [], + "source": [ + "for col in NODE_COLS + EDGE_COLS:\n", + " n = db.collection(col).count()\n", + " print(f' {col:<15}: {n:>8,}')\n", + "\n", + "labeled_count = list(db.aql.execute(\n", + " 'FOR p IN Papers FILTER HAS(p, \"final_decision\") COLLECT WITH COUNT INTO n RETURN n'\n", + "))[0]\n", + "print(f'\\n Papers with ground-truth label: {labeled_count:,}')" + ] + } + ] +} diff --git a/GraphRAG.ipynb b/GraphRAG.ipynb index a520090..bb2ba1d 100644 --- a/GraphRAG.ipynb +++ b/GraphRAG.ipynb @@ -1,691 +1,433 @@ { - "nbformat": 4, - "nbformat_minor": 0, - "metadata": { - "colab": { - "provenance": [] - }, - "kernelspec": { - "name": "python3", - "display_name": "Python 3" - }, - "language_info": { - "name": "python" - } + "nbformat": 4, + "nbformat_minor": 5, + "metadata": { + "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}, + "language_info": {"name": "python", "version": "3.10.0"} + }, + "cells": [ + { + "cell_type": "markdown", "id": "md-title", "metadata": {}, + "source": [ + "# GraphRAG โ€” Knowledge-Graph-Augmented Retrieval\n", + "\n", + "**Pipeline (what GraphRAG adds over Plain RAG):**\n", + "1. Same `all-MiniLM-L6-v2` encoding as Plain RAG\n", + "2. Wide cosine search โ†’ top-75 candidates (vs top-3 direct in Plain RAG)\n", + "3. CrossEncoder reranking โ†’ top-3\n", + "4. AQL graph traversal reconstructs full paper abstracts (vs raw chunk text)\n", + "5. Same `deepseek-r1:8b` via Ollama\n", + "\n", + "**Controlled variables** (identical in both notebooks): embedding model, LLM, system prompt, answer extraction, evaluation.\n", + "\n", + "Run on **Colab with GPU** for best performance." + ] }, - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "8v2LQf5_MZW7" - }, - "outputs": [], - "source": [ - "# @title ๐Ÿš€ 1. Install Dependencies & Setup\n", - "# This cell installs the necessary libraries to talk to ArangoDB and process the data.\n", - "# Run this cell first!\n", - "\n", - "!pip install python-arango datasets ollama gradio sentence-transformers -q\n", - "\n", - "import time\n", - "from getpass import getpass\n", - "from datasets import load_dataset\n", - "from sentence_transformers import SentenceTransformer\n", - "import subprocess\n", - "import requests\n", - "import sys\n", - "import re\n", - "import numpy as np\n", - "import warnings\n", - "from typing import List, Dict\n", - "from arango.exceptions import ServerConnectionError, ArangoServerError\n", - "from sklearn.metrics.pairwise import cosine_similarity\n", - "from tqdm import tqdm\n", - "import os\n", - "import pickle\n", - "from sentence_transformers import CrossEncoder\n", - "import pandas as pd\n", - "import matplotlib.pyplot as plt\n", - "import seaborn as sns\n", - "from sklearn.metrics import accuracy_score, classification_report, confusion_matrix\n", - "import gradio as gr\n", - "\n", - "!curl -fsSL https://ollama.com/install.sh | sh\n", - "\n", - "\n", - "print(\"โœ… Libraries installed.\")" - ] - }, - { - "cell_type": "code", - "source": [ - "def check_and_pull_model(model_name=\"deepseek-r1:8b\"):\n", - " \"\"\"\n", - " Checks if the model exists in Ollama. If not, pulls it automatically.\n", - " \"\"\"\n", - " print(f\"๐Ÿ•ต๏ธ [Ollama] Checking for model: {model_name}...\")\n", - "\n", - " # 1. Check list of models\n", - " try:\n", - " result = subprocess.run([\"ollama\", \"list\"], capture_output=True, text=True)\n", - " if model_name in result.stdout:\n", - " print(f\"โœ… [Ollama] Model '{model_name}' is ready.\")\n", - " return\n", - " except Exception as e:\n", - " print(f\"โš ๏ธ [Ollama] Could not check model list: {e}\")\n", - "\n", - " # 2. If missing, pull it\n", - " print(f\"โฌ‡๏ธ [Ollama] Model not found. Pulling {model_name} (This takes 2-5 mins)...\")\n", - " try:\n", - " # We use Popen to stream the output so you don't think it hung\n", - " process = subprocess.Popen(\n", - " [\"ollama\", \"pull\", model_name],\n", - " stdout=subprocess.PIPE,\n", - " stderr=subprocess.PIPE\n", - " )\n", - " while True:\n", - " output = process.stderr.readline()\n", - " if output == b'' and process.poll() is not None:\n", - " break\n", - " if output:\n", - " # Print progress to console\n", - " print(output.decode().strip())\n", - "\n", - " print(f\"โœ… [Ollama] Successfully pulled {model_name}!\")\n", - "\n", - " except Exception as e:\n", - " print(f\"โŒ [Ollama] Failed to pull model: {e}\")\n", - " sys.exit(1) # Stop script if model fails\n", - "\n", - "MODEL_NAME = \"deepseek-r1:8b\"\n", - "OLLAMA_API = \"http://localhost:11434/api/chat\"\n", - "\n", - "\n", - "check_and_pull_model()" - ], - "metadata": { - "id": "4zktFyVCj_vW" - }, - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "code", - "source": [ - "# @title\n", - "# --- 3. ROBUST UTILITIES ---\n", - "\n", - "class FuzzyEvaluator:\n", - " \"\"\"Evaluates answers with logic to handle verbosity and synonyms.\"\"\"\n", - "\n", - " def extract_answer(self, text: str) -> str:\n", - " # Strip DeepSeek \"Thinking\" blocks\n", - " clean_text = re.sub(r'.*?', '', text, flags=re.DOTALL).lower()\n", - " # Look for the last explicit declaration\n", - " match = re.search(r'(?:final answer|answer):?\\s*(yes|no|maybe)', clean_text)\n", - " if match: return match.group(1)\n", - " # Fallback: look for isolated words at end of text\n", - " matches = re.findall(r'\\b(yes|no|maybe)\\b', clean_text)\n", - " if matches: return matches[-1]\n", - " return \"maybe\" # Default safety\n", - "\n", - " def is_correct(self, gt: str, pred: str) -> bool:\n", - " gt, pred = gt.lower().strip(), pred.lower().strip()\n", - "\n", - " # 1. Exact Match\n", - " if gt == pred: return True\n", - "\n", - " # 2. Starts With (e.g. \"yes, because...\")\n", - " if pred.startswith(gt + \" \") or pred.startswith(gt + \",\"): return True\n", - "\n", - " # 3. Synonyms\n", - " positive = [\"definitely yes\", \"likely\", \"probable\", \"certainly\"]\n", - " negative = [\"unlikely\", \"doubtful\", \"never\"]\n", - "\n", - " if gt == \"yes\" and any(x in pred for x in positive): return True\n", - " if gt == \"no\" and any(x in pred for x in negative): return True\n", - "\n", - " return False\n", - "\n", - "class ArangoConnectionManager:\n", - " \"\"\"Handles the 503 Service Unavailable errors by retrying.\"\"\"\n", - "\n", - " def __init__(self, config):\n", - " self.config = config\n", - " self.client = ArangoClient(hosts=config[\"hosts\"])\n", - " self.db = self._connect_with_retry()\n", - "\n", - " def _connect_with_retry(self, max_retries=5):\n", - " for attempt in range(max_retries):\n", - " try:\n", - " # verify connection\n", - " sys_db = self.client.db(\"_system\", username=self.config[\"username\"], password=self.config[\"password\"])\n", - " sys_db.version() # Ping\n", - "\n", - " # Connect to actual DB\n", - " db = self.client.db(self.config[\"db_name\"], username=self.config[\"username\"], password=self.config[\"password\"])\n", - " print(f\"โœ… [ArangoDB] Connected successfully.\")\n", - " return db\n", - " except (ServerConnectionError, ArangoServerError) as e:\n", - " wait = (attempt + 1) * 5\n", - " print(f\"โš ๏ธ [ArangoDB] Connection failed ({e}). Retrying in {wait}s...\")\n", - " time.sleep(wait)\n", - "\n", - " raise ConnectionError(\"Could not connect to ArangoDB after retries.\")" - ], - "metadata": { - "id": "_iTxmLlfNGNB" - }, - "execution_count": 4, - "outputs": [] - }, - { - "cell_type": "code", - "source": [ - "# ==========================================\n", - "# 1. THE CACHING FUNCTION (Defined locally)\n", - "# ==========================================\n", - "def load_vectors_smartly(db, collection_name, cache_file=\"pubmed_vectors_cache.pkl\"):\n", - " \"\"\"\n", - " Handles the logic: Check Disk -> If Missing, Download -> Save to Disk.\n", - " \"\"\"\n", - " # A. Check Disk\n", - " if os.path.exists(cache_file):\n", - " print(f\"๐Ÿ’พ [Cache] Found local file: {cache_file}\")\n", - " try:\n", - " with open(cache_file, 'rb') as f:\n", - " data = pickle.load(f)\n", - " ids = data.get('ids', [])\n", - " texts = data.get('texts', [])\n", - " embeddings = data.get('embeddings', [])\n", - "\n", - " if len(embeddings) > 0:\n", - " print(f\"โœ… [Cache] Loaded {len(embeddings)} vectors from disk instantly.\")\n", - " return ids, texts, embeddings\n", - " except Exception as e:\n", - " print(f\"โš ๏ธ [Cache] File corrupted ({e}). Re-downloading...\")\n", - "\n", - " # B. Download from Cloud (Only if A failed)\n", - " print(f\"โ˜๏ธ [Index] Cache missing. Downloading from ArangoDB (This happens only once)...\")\n", - "\n", - " ids, texts, embeddings = [], [], []\n", - "\n", - " # Get Count\n", - " try:\n", - " count = db.aql.execute(f\"RETURN LENGTH({collection_name})\").next()\n", - " except:\n", - " count = 200000\n", - "\n", - " # Paged Download\n", - " BATCH_SIZE = 5000\n", - " offset = 0\n", - "\n", - " with tqdm(total=count, desc=\"Downloading Index\", unit=\"vec\") as pbar:\n", - " while True:\n", - " aql = f\"\"\"\n", - " FOR c IN {collection_name}\n", - " FILTER c.embedding != null\n", - " LIMIT {offset}, {BATCH_SIZE}\n", - " RETURN {{ \"id\": c._id, \"text\": c.text, \"emb\": c.embedding }}\n", - " \"\"\"\n", - " try:\n", - " cursor = db.aql.execute(aql, ttl=3600)\n", - " batch_count = 0\n", - " for doc in cursor:\n", - " ids.append(doc[\"id\"])\n", - " texts.append(doc[\"text\"])\n", - " embeddings.append(doc[\"emb\"])\n", - " batch_count += 1\n", - "\n", - " pbar.update(batch_count)\n", - " offset += batch_count\n", - " if batch_count < BATCH_SIZE: break\n", - " time.sleep(0.1) # Be gentle on the server\n", - " except Exception as e:\n", - " print(f\"โš ๏ธ Error on batch: {e}\")\n", - " if \"503\" in str(e): time.sleep(5)\n", - " else: break\n", - "\n", - " # C. Save to Disk\n", - " embeddings_np = np.array(embeddings)\n", - " if len(ids) > 0:\n", - " print(f\"๐Ÿ’พ [Cache] Saving {len(ids)} vectors to {cache_file}...\")\n", - " with open(cache_file, 'wb') as f:\n", - " pickle.dump({'ids': ids, 'texts': texts, 'embeddings': embeddings_np}, f)\n", - " print(\"โœ… [Cache] Saved.\")\n", - "\n", - " return ids, texts, embeddings_np\n", - "\n", - "class RobustGraphRAG:\n", - " def __init__(self, config):\n", - " self.config = config\n", - " self.client = ArangoClient(hosts=config[\"hosts\"])\n", - " self.db = self.client.db(config[\"db_name\"], username=config[\"username\"], password=config[\"password\"])\n", - "\n", - " print(\"โณ [Model] Loading Encoders...\")\n", - " self.encoder = SentenceTransformer(\"all-MiniLM-L6-v2\")\n", - " self.reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')\n", - "\n", - " self.chunk_ids, self.chunk_texts, self.chunk_embeddings = load_vectors_smartly(\n", - " self.db,\n", - " self.config['chunk_col']\n", - " )\n", - "\n", - " def retrieve(self, query: str, top_k=3):\n", - " if len(self.chunk_embeddings) == 0: return \"No context.\"\n", - "\n", - " # 1. Wider Vector Search (75 candidates)\n", - " # We widen this to ensure we catch \"Conclusion\" chunks that might use different wording\n", - " query_emb = self.encoder.encode([query])\n", - " sims = cosine_similarity(query_emb, self.chunk_embeddings)[0]\n", - " top_n_indices = np.argsort(sims)[-75:][::-1]\n", - "\n", - " candidate_pairs = []\n", - " for idx in top_n_indices:\n", - " candidate_pairs.append((self.chunk_texts[idx], self.chunk_ids[idx]))\n", - "\n", - " # 2. Re-Ranking\n", - " cross_inputs = [[query, text] for text, _ in candidate_pairs]\n", - " scores = self.reranker.predict(cross_inputs)\n", - " ranked_indices = np.argsort(scores)[::-1]\n", - "\n", - " best_chunk_ids = []\n", - " for i in range(top_k):\n", - " idx = ranked_indices[i]\n", - " _, cid = candidate_pairs[idx]\n", - " best_chunk_ids.append(cid)\n", - "\n", - " # 3. Graph Expansion (Parent Abstract Reconstruction)\n", - " aql = \"\"\"\n", - " WITH Papers, Chunks\n", - " FOR start_chunk_id IN @ids\n", - " LET start_doc = DOCUMENT(start_chunk_id)\n", - "\n", - " // Find Parent Paper\n", - " FOR paper IN 1..1 INBOUND start_doc HAS_CONTEXT\n", - "\n", - " // Get ALL chunks (Introduction + Results + Conclusion)\n", - " LET full_text_chunks = (\n", - " FOR c IN 1..1 OUTBOUND paper HAS_CONTEXT\n", - " RETURN c.text\n", - " )\n", - "\n", - " // Concatenate into a clean abstract\n", - " LET full_abstract = CONCAT_SEPARATOR(\" \", full_text_chunks)\n", - "\n", - " RETURN {\n", - " \"title\": paper.title,\n", - " \"abstract\": full_abstract\n", - " }\n", - " \"\"\"\n", - "\n", - " try:\n", - " cursor = self.db.aql.execute(aql, bind_vars={\"ids\": best_chunk_ids})\n", - " context_parts = []\n", - " seen_titles = set()\n", - "\n", - " for res in cursor:\n", - " title = res.get('title', 'Unknown')\n", - " if title in seen_titles: continue\n", - " seen_titles.add(title)\n", - "\n", - " # Add \"Study X\" header to help LLM distinguish separate papers\n", - " entry = (\n", - " f\"=== STUDY: {title} ===\\n\"\n", - " f\"ABSTRACT: {res.get('abstract')}\\n\"\n", - " )\n", - " context_parts.append(entry)\n", - "\n", - " return \"\\n\".join(context_parts)\n", - "\n", - " except Exception as e:\n", - " print(f\"โš ๏ธ Graph Error ({e}).\")\n", - " fallback_texts = []\n", - " for i in range(top_k):\n", - " idx = ranked_indices[i]\n", - " t, _ = candidate_pairs[idx]\n", - " fallback_texts.append(f\"Excerpt: {t}\")\n", - " return \"\\n\".join(fallback_texts)\n", - "\n", - " def _heuristic_override(self, response_text):\n", - " \"\"\"\n", - " Python Safety Net: Catches 'Maybe' and flips it if strong keywords exist.\n", - " \"\"\"\n", - " clean_text = response_text.lower()\n", - "\n", - " # 1. Extract the explicit answer\n", - " match = re.search(r'(?:final answer|answer):?\\s*(yes|no|maybe)', clean_text)\n", - " pred = match.group(1) if match else \"maybe\"\n", - "\n", - " # 2. If prediction is YES or NO, trust the model.\n", - " if pred in [\"yes\", \"no\"]:\n", - " return pred\n", - "\n", - " # 3. If prediction is MAYBE, check the REASONING for \"Soft Signals\"\n", - " # Positive Signals\n", - " soft_yes = [\"suggests\", \"indicates\", \"significant\", \"associated with\", \"effective\", \"improved\"]\n", - " for word in soft_yes:\n", - " if word in clean_text:\n", - " return \"yes\"\n", - "\n", - " # Negative Signals\n", - " soft_no = [\"no significant\", \"did not\", \"unrelated\", \"ineffective\", \"no difference\"]\n", - " for word in soft_no:\n", - " if word in clean_text:\n", - " return \"no\"\n", - "\n", - " return \"maybe\"\n", - "\n", - " def query_ollama(self, prompt: str):\n", - " # The \"Calibration\" Prompt\n", - " # We align the model with PubMedQA's specific annotation style.\n", - "\n", - " system_msg = \"\"\"\n", - " You are a PubMedQA annotator.\n", - " Your task is to classify the answer as 'yes', 'no', or 'maybe' based on the Study Abstract.\n", - "\n", - " ANNOTATION GUIDELINES (CRITICAL):\n", - " 1. If the study suggests a positive outcome, even if \"further study is needed\", the answer is YES.\n", - " 2. If the study finds a correlation or association, the answer is YES.\n", - " 3. If the study finds \"no significant difference\", the answer is NO.\n", - " 4. ONLY use MAYBE if the abstract explicitly states \"results were inconclusive\" or provides zero data.\n", - "\n", - " Format:\n", - " Final Answer: [yes/no/maybe]\n", - " \"\"\"\n", - "\n", - " full_prompt = f\"{system_msg}\\n\\nContext:\\n{prompt}\"\n", - "\n", - " url = \"http://localhost:11434/api/chat\"\n", - " payload = {\n", - " \"model\": \"deepseek-r1:8b\",\n", - " \"messages\": [{\"role\": \"user\", \"content\": full_prompt}],\n", - " \"stream\": False,\n", - " \"options\": {\n", - " \"temperature\": 0.0,\n", - " \"num_ctx\": 4096\n", - " }\n", - " }\n", - " try:\n", - " res = requests.post(url, json=payload, timeout=300)\n", - " if res.status_code == 200:\n", - " raw_response = res.json()['message']['content']\n", - "\n", - " # --- APPLY THE PYTHON SAFETY NET ---\n", - " final_decision = self._heuristic_override(raw_response)\n", - "\n", - " # Return a format that your evaluator can parse\n", - " return f\"{raw_response}\\n\\n[Heuristic Override Result]: Final Answer: {final_decision}\"\n", - "\n", - " return f\"Error {res.status_code}\"\n", - " except Exception as e:\n", - " return f\"Exception: {e}\"\n", - "\n", - "\n", - "\n", - " def generate_chat_response(self, message, context):\n", - " \"\"\"\n", - " A specific prompt for the Chat UI (Conversational, not Yes/No).\n", - " \"\"\"\n", - " system_msg = \"\"\"\n", - " You are a Helpful Medical AI Assistant.\n", - " Use the provided Research Abstracts to answer the user's question accurately.\n", - "\n", - " Guidelines:\n", - " 1. Base your answer ONLY on the context provided.\n", - " 2. Cite the specific study titles when making claims (e.g., \"According to the study on X...\").\n", - " 3. If the studies are conflicting, explain the conflict.\n", - " 4. If the answer is not in the context, admit you don't have evidence but give your opinion.\n", - " \"\"\"\n", - "\n", - " full_prompt = f\"{system_msg}\\n\\nContext:\\n{context}\\n\\nUser Question: {message}\"\n", - "\n", - " url = \"http://localhost:11434/api/chat\"\n", - " payload = {\n", - " \"model\": \"deepseek-r1:8b\",\n", - " \"messages\": [{\"role\": \"user\", \"content\": full_prompt}],\n", - " \"stream\": False,\n", - " \"options\": {\"temperature\": 0.3, \"num_ctx\": 4096} # Slight creativity allowed\n", - " }\n", - " try:\n", - " res = requests.post(url, json=payload, timeout=300)\n", - " if res.status_code == 200:\n", - " return res.json()['message']['content']\n", - " return \"Error: Could not communicate with model.\"\n", - " except Exception as e:\n", - " return f\"Error: {e}\"\n", - "\n", - " # --- THE UI LAUNCHER ---\n", - " def launch_gradio_ui(self):\n", - " print(\"\\n๐Ÿš€ Launching Gradio UI...\")\n", - "\n", - " def chat_logic(message, history):\n", - " # 1. Retrieve Context\n", - " print(f\"๐Ÿ”Ž Retrieving for: {message}...\")\n", - " retrieved_context = self.retrieve(message)\n", - "\n", - " # 2. Generate Answer\n", - " print(f\"๐Ÿค– Generating Answer...\")\n", - " response = self.generate_chat_response(message, retrieved_context)\n", - "\n", - " # 3. Optional: Append Sources to the bottom of the answer\n", - " final_output = f\"{response}\\n\\n___\\n**Sources Retrieved:**\\n\"\n", - "\n", - " # Simple regex to extract titles for display\n", - " titles = re.findall(r\"=== STUDY: (.*?) ===\", retrieved_context)\n", - " for t in titles:\n", - " final_output += f\"- *{t}*\\n\"\n", - "\n", - " return final_output\n", - "\n", - " # Create the Interface\n", - " demo = gr.ChatInterface(\n", - " fn=chat_logic,\n", - " title=\"๐Ÿงฌ PubMed GraphRAG Assistant\",\n", - " description=\"Ask detailed medical questions. I will retrieve full abstracts from the Knowledge Graph to answer you.\",\n", - " examples=[\n", - " \"Do preoperative statins reduce atrial fibrillation?\",\n", - " \"Is obesity a risk factor for cirrhosis-related death or hospitalization?\",\n", - " \"Does high-dose aspirin prevent cardiovascular events?\"\n", - " ],\n", - " theme=\"soft\"\n", - " )\n", - "\n", - " demo.launch(share=True, debug=True)" - ], - "metadata": { - "id": "djEmPjhjNLej" - }, - "execution_count": 5, - "outputs": [] - }, - { - "cell_type": "code", - "source": [ - "class AdvancedEvaluator:\n", - " def __init__(self):\n", - " self.y_true = []\n", - " self.y_pred = []\n", - " self.start_time = None\n", - " self.end_time = None\n", - "\n", - " def start(self):\n", - " \"\"\"Starts the stopwatch.\"\"\"\n", - " self.start_time = time.time()\n", - " print(\"โฑ๏ธ Evaluation Timer Started...\")\n", - "\n", - " def stop(self):\n", - " \"\"\"Stops the stopwatch.\"\"\"\n", - " self.end_time = time.time()\n", - "\n", - " def record(self, gt, pred):\n", - " \"\"\"Records a single prediction pair.\"\"\"\n", - " # Normalize to ensure clean metrics\n", - " clean_gt = gt.lower().strip()\n", - " clean_pred = pred.lower().strip()\n", - "\n", - " # Safety: If model output garbage, classify as 'maybe'\n", - " if clean_pred not in ['yes', 'no', 'maybe']:\n", - " clean_pred = 'maybe'\n", - "\n", - " self.y_true.append(clean_gt)\n", - " self.y_pred.append(clean_pred)\n", - "\n", - " def generate_report(self):\n", - " \"\"\"Calculates and visualizes all requested metrics.\"\"\"\n", - " if not self.y_true:\n", - " print(\"โš ๏ธ No data to report.\")\n", - " return\n", - "\n", - " # 1. Total Time\n", - " total_seconds = self.end_time - self.start_time\n", - " avg_per_sample = total_seconds / len(self.y_true)\n", - "\n", - " # 2. Accuracy\n", - " acc = accuracy_score(self.y_true, self.y_pred) * 100\n", - "\n", - " print(\"\\n\" + \"=\"*40)\n", - " print(f\"๐Ÿ“Š FINAL EVALUATION REPORT\")\n", - " print(\"=\"*40)\n", - " print(f\"โฑ๏ธ Total Time: {total_seconds:.2f} seconds\")\n", - " print(f\"โšก Avg Latency: {avg_per_sample:.2f} seconds/query\")\n", - " print(f\"๐ŸŽฏ Final Accuracy: {acc:.2f}%\")\n", - " print(\"-\" * 40)\n", - "\n", - " # 3. Prediction Summary (Counts)\n", - " df = pd.DataFrame({'Ground Truth': self.y_true, 'Prediction': self.y_pred})\n", - " print(\"\\n๐Ÿ“‹ Prediction Distribution:\")\n", - " print(df['Prediction'].value_counts())\n", - "\n", - " # 4. Classification Report\n", - " print(\"\\n๐Ÿ“ˆ Detailed Classification Report:\")\n", - " # We specify labels to ensure all classes show up even if count is 0\n", - " labels = ['yes', 'no', 'maybe']\n", - " print(classification_report(self.y_true, self.y_pred, labels=labels, zero_division=0))\n", - "\n", - " # 5. Confusion Matrix Visualization\n", - " cm = confusion_matrix(self.y_true, self.y_pred, labels=labels)\n", - "\n", - " plt.figure(figsize=(8, 6))\n", - " sns.set(font_scale=1.2)\n", - " sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',\n", - " xticklabels=labels, yticklabels=labels)\n", - " plt.xlabel('Predicted Label')\n", - " plt.ylabel('True Label')\n", - " plt.title('Confusion Matrix: PubMedQA Evaluation')\n", - " plt.show()" - ], - "metadata": { - "id": "fIPqChKGR_uN" - }, - "execution_count": 6, - "outputs": [] - }, - { - "cell_type": "code", - "source": [ - "# @title\n", - "# --- 5. MAIN EXECUTION (MERGED) ---\n", - "if __name__ == \"__main__\":\n", - "\n", - " # 1. Start Server (Background)\n", - " print(\"๐Ÿš€ [Ollama] Ensuring server is running...\")\n", - " subprocess.Popen([\"ollama\", \"serve\"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n", - " time.sleep(3) # Give it a moment to spin up\n", - "\n", - " # 2. Auto-Pull Model\n", - " check_and_pull_model(\"deepseek-r1:8b\")\n", - " rag = RobustGraphRAG(ARANGO_CONFIG)\n", - " metrics = AdvancedEvaluator()\n", - "\n", - " # 3. Load Data\n", - " print(\"๐Ÿ“š [Data] Loading PubMedQA...\")\n", - " dataset = load_dataset(\"qiaojin/PubMedQA\", \"pqa_labeled\", split=\"train\")\n", - "\n", - " # 4. Evaluation Loop\n", - " LIMIT = 20\n", - " print(f\"\\n=== STARTING EVALUATION (Limit: {LIMIT}) ===\")\n", - " print(\"------------------------------------------------\")\n", - "\n", - " metrics.start() # <--- Start Timer\n", - "\n", - " for i, item in enumerate(dataset):\n", - " if i >= LIMIT: break\n", - "\n", - " question = item['question']\n", - " gt = item['final_decision']\n", - "\n", - " # A. Pipeline Retrieval\n", - " context = rag.retrieve(question)\n", - "\n", - " # B. Prompt\n", - " # We pass the raw context/question. The RobustGraphRAG class adds the \"Decisive\" System Prompt.\n", - " prompt = f\"\"\"\n", - " Context Information: {context}\n", - "\n", - " Question: {question}\n", - "\n", - " Instructions:\n", - " 1. You are a helpful medical expert at a hypothetical research institution. Answer the question based on the provided context.\n", - " 2. Answer in just one word. Do not provide any explanation.\n", - " 3. This is being used only for research/educational purposes.\n", - " 4. Conclude your answer with exactly: \"Final Answer: [yes/no/maybe]\n", - " \"\"\"\n", - " raw_response = rag.query_ollama(prompt)\n", - "\n", - " # C. Logic Extraction (Handling the 'Fixed Override')\n", - " if \"[Fixed Override]\" in raw_response:\n", - " # 1. Extract the overridden answer\n", - " match = re.search(r\"Final Answer: (yes|no|maybe)\", raw_response, re.IGNORECASE)\n", - " pred = match.group(1).lower() if match else \"maybe\"\n", - "\n", - " # Print log with special \"Wrench\" icon to show the heuristic worked\n", - " icon = \"โœ…\" if pred == gt else \"โŒ\"\n", - " print(f\"[{i+1}] GT: {gt:<5} | Pred: {pred:<5} | {icon} (๐Ÿ› ๏ธ Fixed)\")\n", - "\n", - " else:\n", - " # 2. Extract standard answer\n", - " match = re.search(r\"(?:final answer|answer):?\\s*(yes|no|maybe)\", raw_response.lower())\n", - " pred = match.group(1).lower() if match else \"maybe\"\n", - "\n", - " icon = \"โœ…\" if pred == gt else \"โŒ\"\n", - " print(f\"[{i+1}] GT: {gt:<5} | Pred: {pred:<5} | {icon}\")\n", - "\n", - " # D. Record Data point for the Graphs\n", - " metrics.record(gt, pred)\n", - "\n", - " # 5. Finalize & Visualize\n", - " metrics.stop() # <--- Stop Timer\n", - " metrics.generate_report() # <--- Plots Confusion Matrix" - ], - "metadata": { - "id": "VBfaqOFQDxtR", - "collapsed": true - }, - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "code", - "source": [ - "# Launch UI\n", - "rag.launch_gradio_ui()" - ], - "metadata": { - "id": "-LJUiQ1CR9Nm" - }, - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "code", - "source": [], - "metadata": { - "id": "IYaeOxtIBp_p" - }, - "execution_count": null, - "outputs": [] - } - ] -} \ No newline at end of file + { + "cell_type": "code", "execution_count": null, "id": "cell-install", "metadata": {}, "outputs": [], + "source": [ + "!pip install python-arango sentence-transformers datasets gradio -q\n", + "!curl -fsSL https://ollama.com/install.sh | sh" + ] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-imports", "metadata": {}, "outputs": [], + "source": [ + "import os, re, sys, time, json, pickle, subprocess, requests\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import seaborn as sns\n", + "from sklearn.metrics import accuracy_score, classification_report, confusion_matrix\n", + "from sklearn.metrics.pairwise import cosine_similarity\n", + "from sentence_transformers import SentenceTransformer, CrossEncoder\n", + "from datasets import load_dataset\n", + "from arango import ArangoClient\n", + "from arango.exceptions import ServerConnectionError, ArangoServerError\n", + "from tqdm import tqdm\n", + "import gradio as gr\n", + "print('Imports OK')" + ] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-shared", "metadata": {}, "outputs": [], + "source": [ + "# โ”€โ”€ Shared constants (word-for-word identical in Plain_RAG.ipynb) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "EMBEDDING_MODEL = 'sentence-transformers/all-MiniLM-L6-v2'\n", + "LLM_MODEL = 'deepseek-r1:8b'\n", + "OLLAMA_API = 'http://localhost:11434/api/chat'\n", + "TOP_K_FINAL = 3\n", + "\n", + "BENCHMARK_SYSTEM_PROMPT = (\n", + " 'You are a PubMedQA annotator. Classify the answer as yes, no, or maybe.\\n\\n'\n", + " 'Guidelines:\\n'\n", + " '- YES : the study finds a positive outcome, correlation, or association,\\n'\n", + " ' even if further research is recommended.\\n'\n", + " '- NO : the study finds no significant difference or a negative result.\\n'\n", + " '- MAYBE: only if the abstract explicitly states inconclusive results\\n'\n", + " ' with no supporting data.\\n\\n'\n", + " 'End your response with exactly: Final Answer: [yes/no/maybe]'\n", + ")\n", + "\n", + "CHAT_SYSTEM_PROMPT = (\n", + " 'You are a helpful medical AI assistant. '\n", + " 'Use the provided research abstracts to answer the user question. '\n", + " 'Cite specific study titles when making claims. '\n", + " 'If studies conflict, explain the conflict. '\n", + " 'If the context is insufficient, say so and give your best assessment.'\n", + ")\n", + "\n", + "\n", + "class FuzzyEvaluator:\n", + " \"\"\"Extracts and normalises yes/no/maybe from verbose model output.\"\"\"\n", + " def extract_answer(self, text):\n", + " clean = re.sub(r'.*?', '', text, flags=re.DOTALL).lower()\n", + " m = re.search(r'final answer\\s*:\\s*(yes|no|maybe)', clean)\n", + " if m: return m.group(1)\n", + " hits = re.findall(r'\\b(yes|no|maybe)\\b', clean)\n", + " return hits[-1] if hits else 'maybe'\n", + "\n", + "\n", + "class Evaluator:\n", + " \"\"\"Records predictions and generates a full evaluation report.\"\"\"\n", + " def __init__(self, model_name):\n", + " self.model_name = model_name\n", + " self.y_true, self.y_pred, self.latencies = [], [], []\n", + "\n", + " def record(self, gt, pred, latency=0.0):\n", + " p = pred.lower().strip()\n", + " if p not in ('yes', 'no', 'maybe'): p = 'maybe'\n", + " self.y_true.append(gt.lower().strip())\n", + " self.y_pred.append(p)\n", + " self.latencies.append(latency)\n", + "\n", + " def report(self):\n", + " if not self.y_true: print('No data.'); return {}\n", + " labels = ['yes', 'no', 'maybe']\n", + " acc = accuracy_score(self.y_true, self.y_pred)\n", + " total = sum(self.latencies)\n", + " avg = total / len(self.latencies)\n", + " print(f\"\\n{'='*52}\\n {self.model_name} โ€” Evaluation Report\\n{'='*52}\")\n", + " print(f' Samples : {len(self.y_true)} | Accuracy : {acc:.2%} | Avg latency : {avg:.1f}s')\n", + " print(f\"{'โ”€'*52}\")\n", + " print(classification_report(self.y_true, self.y_pred, labels=labels, zero_division=0))\n", + " cm = confusion_matrix(self.y_true, self.y_pred, labels=labels)\n", + " fig, ax = plt.subplots(figsize=(6, 5))\n", + " sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',\n", + " xticklabels=labels, yticklabels=labels, ax=ax)\n", + " ax.set_xlabel('Predicted'); ax.set_ylabel('Actual')\n", + " ax.set_title(f'Confusion Matrix โ€” {self.model_name}')\n", + " plt.tight_layout(); plt.show()\n", + " return {'model': self.model_name, 'accuracy': acc, 'samples': len(self.y_true),\n", + " 'total_time': total, 'avg_latency': avg,\n", + " 'y_true': self.y_true, 'y_pred': self.y_pred}\n", + "\n", + " def save(self, path):\n", + " data = {'model': self.model_name,\n", + " 'accuracy': accuracy_score(self.y_true, self.y_pred) if self.y_true else 0,\n", + " 'samples': len(self.y_true), 'total_time': sum(self.latencies),\n", + " 'avg_latency': sum(self.latencies)/len(self.latencies) if self.latencies else 0,\n", + " 'y_true': self.y_true, 'y_pred': self.y_pred}\n", + " with open(path, 'w') as f: json.dump(data, f, indent=2)\n", + " print(f'Results saved to {path}')\n", + "\n", + "\n", + "def call_ollama(prompt, system='', temperature=0.0, model=LLM_MODEL):\n", + " messages = []\n", + " if system: messages.append({'role': 'system', 'content': system})\n", + " messages.append({'role': 'user', 'content': prompt})\n", + " payload = {'model': model, 'messages': messages, 'stream': False,\n", + " 'options': {'temperature': temperature, 'num_ctx': 4096}}\n", + " resp = requests.post(OLLAMA_API, json=payload, timeout=300)\n", + " resp.raise_for_status()\n", + " return resp.json()['message']['content']\n", + "\n", + "\n", + "print('Shared utilities ready.')" + ] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-config", "metadata": {}, "outputs": [], + "source": [ + "# โ”€โ”€ ArangoDB credentials โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "# In Colab: add ARANGO_PASS in Secrets (key icon in sidebar)\n", + "try:\n", + " from google.colab import userdata\n", + " ARANGO_PASS = userdata.get('ARANGO_PASS')\n", + "except Exception:\n", + " ARANGO_PASS = os.environ.get('ARANGO_PASS', '')\n", + "\n", + "ARANGO_HOST = os.environ.get('ARANGO_HOST', 'https://bfc25a0e3c74.arangodb.cloud:8529')\n", + "ARANGO_USER = os.environ.get('ARANGO_USER', 'root')\n", + "ARANGO_DB = os.environ.get('ARANGO_DB', 'pubmed_graph')\n", + "\n", + "# โ”€โ”€ GraphRAG-specific parameters โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "TOP_K_CANDIDATES = 75\n", + "CROSS_ENCODER = 'cross-encoder/ms-marco-MiniLM-L-6-v2'\n", + "VECTOR_CACHE_FILE = 'pubmed_vectors_cache.pkl'\n", + "RESULTS_DIR = 'results'\n", + "RESULTS_FILE = os.path.join(RESULTS_DIR, 'graphrag_results.json')\n", + "BENCHMARK_N = 100\n", + "\n", + "os.makedirs(RESULTS_DIR, exist_ok=True)\n", + "\n", + "if not ARANGO_PASS:\n", + " raise ValueError('Set ARANGO_PASS in Colab Secrets (key icon) or as an env var.')\n", + "\n", + "print(f'Config ready. BENCHMARK_N={BENCHMARK_N}, results -> {RESULTS_FILE}')" + ] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-ollama", "metadata": {}, "outputs": [], + "source": [ + "def ensure_ollama(model=LLM_MODEL):\n", + " subprocess.Popen(['ollama', 'serve'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n", + " time.sleep(3)\n", + " result = subprocess.run(['ollama', 'list'], capture_output=True, text=True)\n", + " if model in result.stdout:\n", + " print(f'[Ollama] {model} is ready.')\n", + " return\n", + " print(f'[Ollama] Pulling {model}...')\n", + " subprocess.run(['ollama', 'pull', model], check=True)\n", + " print(f'[Ollama] {model} ready.')\n", + "\n", + "ensure_ollama()" + ] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-arango", "metadata": {}, "outputs": [], + "source": [ + "def connect_arango(host, user, password, db_name, max_retries=5):\n", + " client = ArangoClient(hosts=host)\n", + " for attempt in range(max_retries):\n", + " try:\n", + " sys_db = client.db('_system', username=user, password=password)\n", + " sys_db.version()\n", + " db = client.db(db_name, username=user, password=password)\n", + " print('[ArangoDB] Connected.')\n", + " return db\n", + " except (ServerConnectionError, ArangoServerError) as exc:\n", + " wait = (attempt + 1) * 5\n", + " print(f'[ArangoDB] Attempt {attempt+1} failed. Retrying in {wait}s...')\n", + " time.sleep(wait)\n", + " raise ConnectionError('Could not connect to ArangoDB.')\n", + "\n", + "db = connect_arango(ARANGO_HOST, ARANGO_USER, ARANGO_PASS, ARANGO_DB)" + ] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-cache", "metadata": {}, "outputs": [], + "source": [ + "def load_chunk_vectors(db, collection='Chunks', cache_file=VECTOR_CACHE_FILE):\n", + " if os.path.exists(cache_file):\n", + " print(f'[Cache] Loading from {cache_file}...')\n", + " try:\n", + " with open(cache_file, 'rb') as f: data = pickle.load(f)\n", + " ids, texts, embs = data['ids'], data['texts'], data['embeddings']\n", + " if len(embs) > 0:\n", + " print(f'[Cache] Loaded {len(embs):,} vectors.')\n", + " return ids, texts, np.array(embs)\n", + " except Exception as exc:\n", + " print(f'[Cache] Corrupted ({exc}). Re-downloading...')\n", + "\n", + " print('[Index] Downloading vectors from ArangoDB...')\n", + " ids, texts, embs = [], [], []\n", + " BATCH, offset = 5000, 0\n", + " try: total = list(db.aql.execute(f'RETURN LENGTH({collection})'))[0]\n", + " except: total = 0\n", + "\n", + " with tqdm(total=total, desc='Downloading') as pbar:\n", + " while True:\n", + " aql = f'''\n", + " FOR c IN {collection}\n", + " FILTER c.embedding != null\n", + " LIMIT {offset}, {BATCH}\n", + " RETURN {{ id: c._id, text: c.text, emb: c.embedding }}\n", + " '''\n", + " try: batch = list(db.aql.execute(aql, ttl=3600))\n", + " except Exception as exc:\n", + " print(f'Batch error: {exc}')\n", + " if '503' in str(exc): time.sleep(5)\n", + " break\n", + " if not batch: break\n", + " for doc in batch:\n", + " ids.append(doc['id']); texts.append(doc['text']); embs.append(doc['emb'])\n", + " pbar.update(len(batch)); offset += len(batch)\n", + " if len(batch) < BATCH: break\n", + " time.sleep(0.1)\n", + "\n", + " embs_np = np.array(embs)\n", + " if ids:\n", + " with open(cache_file, 'wb') as f:\n", + " pickle.dump({'ids': ids, 'texts': texts, 'embeddings': embs_np}, f)\n", + " print(f'[Cache] Saved {len(ids):,} vectors.')\n", + " return ids, texts, embs_np" + ] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-graphrag-class", "metadata": {}, "outputs": [], + "source": [ + "class GraphRAG:\n", + " \"\"\"\n", + " Graph-augmented RAG. Advantages over Plain RAG:\n", + " - Wide retrieval (top-75) + CrossEncoder reranking\n", + " - AQL traversal reconstructs full coherent abstracts\n", + " - MeSH concept graph links papers semantically\n", + " LLM, embedding model, prompt, and evaluation are identical to Plain RAG.\n", + " \"\"\"\n", + "\n", + " def __init__(self, db, chunk_ids, chunk_texts, chunk_embeddings):\n", + " self.db = db\n", + " self.chunk_ids, self.chunk_texts, self.chunk_embeddings = chunk_ids, chunk_texts, chunk_embeddings\n", + " print('[Model] Loading Sentence Transformer...')\n", + " self.encoder = SentenceTransformer(EMBEDDING_MODEL)\n", + " print('[Model] Loading CrossEncoder...')\n", + " self.reranker = CrossEncoder(CROSS_ENCODER)\n", + " print('[GraphRAG] Initialised.')\n", + "\n", + " def retrieve(self, query):\n", + " if len(self.chunk_embeddings) == 0: return 'No context available.'\n", + " q_emb = self.encoder.encode([query])\n", + " sims = cosine_similarity(q_emb, self.chunk_embeddings)[0]\n", + " top_idx = np.argsort(sims)[-TOP_K_CANDIDATES:][::-1]\n", + " candidates = [(self.chunk_texts[i], self.chunk_ids[i]) for i in top_idx]\n", + " scores = self.reranker.predict([[query, t] for t, _ in candidates])\n", + " best_idx = np.argsort(scores)[::-1][:TOP_K_FINAL]\n", + " return self._expand_via_graph([candidates[i][1] for i in best_idx])\n", + "\n", + " def _expand_via_graph(self, chunk_ids):\n", + " aql = '''\n", + " WITH Papers, Chunks\n", + " FOR cid IN @ids\n", + " LET chunk = DOCUMENT(cid)\n", + " FOR paper IN 1..1 INBOUND chunk HAS_CONTEXT\n", + " LET all_chunks = (FOR c IN 1..1 OUTBOUND paper HAS_CONTEXT RETURN c.text)\n", + " RETURN { title: paper.title, abstract: CONCAT_SEPARATOR(\" \", all_chunks) }\n", + " '''\n", + " try:\n", + " rows, seen, parts = list(self.db.aql.execute(aql, bind_vars={'ids': chunk_ids})), set(), []\n", + " for row in rows:\n", + " t = row.get('title', 'Unknown')\n", + " if t in seen: continue\n", + " seen.add(t)\n", + " parts.append('=== STUDY: ' + t + ' ===\\n' + row.get('abstract', ''))\n", + " return '\\n\\n'.join(parts) if parts else 'No context found.'\n", + " except Exception as exc:\n", + " print(f'[Graph] Expansion failed ({exc}). Using raw chunks.')\n", + " return '\\n\\n'.join('Excerpt: ' + t for t, _ in zip(self.chunk_texts, chunk_ids))\n", + "\n", + " def answer_benchmark(self, question):\n", + " ctx = self.retrieve(question)\n", + " return call_ollama('Context:\\n' + ctx + '\\n\\nQuestion: ' + question,\n", + " system=BENCHMARK_SYSTEM_PROMPT, temperature=0.0)\n", + "\n", + " def answer_chat(self, question):\n", + " ctx = self.retrieve(question)\n", + " raw = call_ollama('Context:\\n' + ctx + '\\n\\nQuestion: ' + question,\n", + " system=CHAT_SYSTEM_PROMPT, temperature=0.3)\n", + " titles = re.findall(r'=== STUDY: (.*?) ===', ctx)\n", + " src = '\\n'.join('- ' + t for t in titles)\n", + " return raw + '\\n\\n**Sources:**\\n' + src if src else raw" + ] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-init", "metadata": {}, "outputs": [], + "source": [ + "chunk_ids, chunk_texts, chunk_embeddings = load_chunk_vectors(db)\n", + "rag = GraphRAG(db, chunk_ids, chunk_texts, chunk_embeddings)" + ] + }, + { + "cell_type": "markdown", "id": "md-benchmark", "metadata": {}, + "source": ["## Benchmark Evaluation\n", + "Evaluates on `pqa_labeled` (1 000 ground-truth samples). Adjust `BENCHMARK_N` in the config cell."] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-benchmark", "metadata": {}, "outputs": [], + "source": [ + "dataset = load_dataset('qiaojin/PubMedQA', 'pqa_labeled', split='train')\n", + "fuzzy = FuzzyEvaluator()\n", + "evaluator = Evaluator('GraphRAG')\n", + "\n", + "print(f'=== GraphRAG Benchmark (n={BENCHMARK_N}) ===')\n", + "for i, item in enumerate(dataset):\n", + " if i >= BENCHMARK_N: break\n", + " t0 = time.time()\n", + " raw = rag.answer_benchmark(item['question'])\n", + " lat = time.time() - t0\n", + " pred = fuzzy.extract_answer(raw)\n", + " gt = item['final_decision']\n", + " evaluator.record(gt, pred, lat)\n", + " print(f'[{i+1:3d}] GT={gt:<5} Pred={pred:<5} {\"v\" if pred==gt else \"x\"} ({lat:.1f}s)')\n", + "\n", + "results = evaluator.report()" + ] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-save", "metadata": {}, "outputs": [], + "source": ["evaluator.save(RESULTS_FILE)"] + }, + { + "cell_type": "markdown", "id": "md-compare", "metadata": {}, + "source": ["## Head-to-Head Comparison\n", "Run after both notebooks have saved results. Also available as a standalone `Comparison.ipynb`."] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-compare", "metadata": {}, "outputs": [], + "source": [ + "from sklearn.metrics import f1_score\n", + "import pandas as pd\n", + "\n", + "plainrag_path = os.path.join(RESULTS_DIR, 'plainrag_results.json')\n", + "if not os.path.exists(plainrag_path):\n", + " print('Run Plain_RAG.ipynb first, then re-run this cell.')\n", + "else:\n", + " with open(RESULTS_FILE) as f: gr_r = json.load(f)\n", + " with open(plainrag_path) as f: pr_r = json.load(f)\n", + " LABELS = ['yes', 'no', 'maybe']\n", + " models = [gr_r['model'], pr_r['model']]\n", + " accs = [gr_r['accuracy']*100, pr_r['accuracy']*100]\n", + " f1s = [f1_score(r['y_true'], r['y_pred'], labels=LABELS, average='macro', zero_division=0)*100\n", + " for r in [gr_r, pr_r]]\n", + " lats = [gr_r['avg_latency'], pr_r['avg_latency']]\n", + " cols = ['#2196F3', '#FF9800']\n", + "\n", + " fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n", + " for ax, vals, lbl in zip(axes, [accs, f1s, lats],\n", + " ['Accuracy (%)', 'Macro F1 (%)', 'Avg Latency (s)']):\n", + " bars = ax.bar(models, vals, color=cols, width=0.4, edgecolor='white')\n", + " ax.set_title(lbl); ax.set_ylabel(lbl)\n", + " if 'Latency' not in lbl: ax.set_ylim(0, 100)\n", + " for b, v in zip(bars, vals):\n", + " ax.text(b.get_x()+b.get_width()/2, v*1.03 if 'Lat' in lbl else v+1.5,\n", + " f'{v:.1f}', ha='center', fontweight='bold')\n", + " plt.suptitle(f'GraphRAG vs Plain RAG (n={gr_r[\"samples\"]} samples)',\n", + " fontsize=13, fontweight='bold', y=1.02)\n", + " plt.tight_layout(); plt.show()\n", + "\n", + " delta = accs[0] - accs[1]\n", + " winner = models[0] if delta >= 0 else models[1]\n", + " print(f'\\n{winner} wins by {abs(delta):.1f} pp (accuracy delta: {delta:+.1f} pp)')" + ] + }, + { + "cell_type": "markdown", "id": "md-ui", "metadata": {}, + "source": ["## Interactive Chat UI"] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-ui", "metadata": {}, "outputs": [], + "source": [ + "def launch_ui(rag_instance):\n", + " def chat_fn(message, history): return rag_instance.answer_chat(message)\n", + " gr.ChatInterface(\n", + " fn=chat_fn, title='PubMed GraphRAG Assistant',\n", + " description='Graph-augmented retrieval: full abstracts reconstructed via AQL traversal.',\n", + " examples=['Do preoperative statins reduce atrial fibrillation?',\n", + " 'Is obesity a risk factor for cirrhosis-related death?',\n", + " 'Does high-dose aspirin prevent cardiovascular events?'],\n", + " theme='soft',\n", + " ).launch(share=True, debug=True)\n", + "\n", + "launch_ui(rag)" + ] + } + ] +} diff --git a/Plain_RAG/Plain_RAG.ipynb b/Plain_RAG/Plain_RAG.ipynb index d35c42c..a2f0e74 100644 --- a/Plain_RAG/Plain_RAG.ipynb +++ b/Plain_RAG/Plain_RAG.ipynb @@ -1,8831 +1,384 @@ { - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "SwCv3__NIaTo", - "outputId": "ad7bf0ed-b938-45a8-a266-97cd05508255" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\u001b[2K \u001b[90mโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\u001b[0m \u001b[32m59.4/59.4 MB\u001b[0m \u001b[31m33.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25h" - ] - } - ], - "source": [ - "!pip install -q sentence-transformers datasets transformers accelerate bitsandbytes" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "e24TcF9NZ2gO", - "outputId": "071b2bd1-ed2d-403b-b0d6-a110f624fd0e" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Requirement already satisfied: matplotlib in /usr/local/lib/python3.12/dist-packages (3.10.0)\n", - "Requirement already satisfied: seaborn in /usr/local/lib/python3.12/dist-packages (0.13.2)\n", - "Requirement already satisfied: contourpy>=1.0.1 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (1.3.3)\n", - "Requirement already satisfied: cycler>=0.10 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (0.12.1)\n", - "Requirement already satisfied: fonttools>=4.22.0 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (4.60.1)\n", - "Requirement already satisfied: kiwisolver>=1.3.1 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (1.4.9)\n", - "Requirement already satisfied: numpy>=1.23 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (2.0.2)\n", - "Requirement already satisfied: packaging>=20.0 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (25.0)\n", - "Requirement already satisfied: pillow>=8 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (11.3.0)\n", - "Requirement already satisfied: pyparsing>=2.3.1 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (3.2.5)\n", - "Requirement already satisfied: python-dateutil>=2.7 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (2.9.0.post0)\n", - "Requirement already satisfied: pandas>=1.2 in /usr/local/lib/python3.12/dist-packages (from seaborn) (2.2.2)\n", - "Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas>=1.2->seaborn) (2025.2)\n", - "Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas>=1.2->seaborn) (2025.2)\n", - "Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.7->matplotlib) (1.17.0)\n" - ] - } - ], - "source": [ - "!pip install matplotlib seaborn" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "RORhfl85J00q", - "outputId": "1b0c3ab1-d493-4d0d-a58a-9d165875c2d7" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Collecting faiss-gpu-cu12\n", - " Downloading faiss_gpu_cu12-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (12 kB)\n", - "Requirement already satisfied: numpy<3,>=2 in /usr/local/lib/python3.12/dist-packages (from faiss-gpu-cu12) (2.0.2)\n", - "Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from faiss-gpu-cu12) (25.0)\n", - "Requirement already satisfied: nvidia-cuda-runtime-cu12>=12.1.105 in /usr/local/lib/python3.12/dist-packages (from faiss-gpu-cu12) (12.6.77)\n", - "Requirement already satisfied: nvidia-cublas-cu12>=12.1.3.1 in /usr/local/lib/python3.12/dist-packages (from faiss-gpu-cu12) (12.6.4.1)\n", - "Downloading faiss_gpu_cu12-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (48.3 MB)\n", - "\u001b[2K \u001b[90mโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\u001b[0m \u001b[32m48.3/48.3 MB\u001b[0m \u001b[31m30.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hInstalling collected packages: faiss-gpu-cu12\n", - "Successfully installed faiss-gpu-cu12-1.13.0\n" - ] - } - ], - "source": [ - "!pip install faiss-gpu-cu12" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "pVq9qIBETknV" - }, - "outputs": [], - "source": [ - "import os\n", - "import torch\n", - "import faiss\n", - "import numpy as np\n", - "import time\n", - "import pickle\n", - "import gradio as gr\n", - "from tqdm.notebook import tqdm\n", - "from datasets import load_dataset\n", - "from sentence_transformers import SentenceTransformer\n", - "from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig\n", - "from sklearn.metrics import accuracy_score, classification_report\n", - "import matplotlib.pyplot as plt\n", - "import seaborn as sns\n", - "from sklearn.metrics import confusion_matrix\n", - "import pandas as pd" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "X_tuVvKdTohM" - }, - "outputs": [], - "source": [ - "EMBEDDING_MODEL_NAME = \"sentence-transformers/all-mpnet-base-v2\"\n", - "LLM_MODEL_NAME = \"deepseek-ai/DeepSeek-R1-Distill-Llama-8B\"\n", - "INDEX_TYPE = \"IndexFlatIP\" # Inner Product (Cosine Similarity)\n", - "BATCH_SIZE = 128\n", - "TOP_K_RETRIEVAL = 3\n", - "INDEX_FILE = \"pubmed_rag_index.bin\"\n", - "DATA_FILE = \"pubmed_rag_data.pkl\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "nOAJa-drTsrp" - }, - "outputs": [], - "source": [ - "class PubMedRAG:\n", - " def __init__(self):\n", - " self.device = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n", - " print(f\"Initializing RAG Pipeline on {self.device}\")\n", - "\n", - " # 1. Load Embedding Model\n", - " print(f\"Loading Embedding Model: {EMBEDDING_MODEL_NAME}\")\n", - " self.embedder = SentenceTransformer(EMBEDDING_MODEL_NAME, device=self.device)\n", - " self.embedding_dim = self.embedder.get_sentence_embedding_dimension()\n", - "\n", - " # 2. Load LLM (4-bit quantized)\n", - " print(f\"Loading LLM: {LLM_MODEL_NAME}\")\n", - " bnb_config = BitsAndBytesConfig(\n", - " load_in_4bit=True,\n", - " bnb_4bit_compute_dtype=torch.float16,\n", - " bnb_4bit_quant_type=\"nf4\",\n", - " )\n", - " self.tokenizer = AutoTokenizer.from_pretrained(LLM_MODEL_NAME)\n", - " self.llm = AutoModelForCausalLM.from_pretrained(\n", - " LLM_MODEL_NAME,\n", - " quantization_config=bnb_config,\n", - " device_map=\"auto\"\n", - " )\n", - "\n", - " # 3. Initialize placeholders\n", - " self.index = None\n", - " self.documents = []\n", - " self.labeled_data = []\n", - "\n", - " def load_and_index_data(self):\n", - " \"\"\"Loads data. Tries to load from disk first; otherwise builds from scratch.\"\"\"\n", - "\n", - " # --- OPTION A: LOAD FROM DISK ---\n", - " if os.path.exists(INDEX_FILE) and os.path.exists(DATA_FILE):\n", - " print(f\"\\nFound saved index and data on disk!\")\n", - " print(f\" - Loading Index from {INDEX_FILE}\")\n", - " self.index = faiss.read_index(INDEX_FILE)\n", - "\n", - " # Move to GPU if possible\n", - " if self.device == \"cuda\" and hasattr(faiss, \"StandardGpuResources\"):\n", - " try:\n", - " res = faiss.StandardGpuResources()\n", - " self.index = faiss.index_cpu_to_gpu(res, 0, self.index)\n", - " print(\" Index moved to GPU.\")\n", - " except Exception as e:\n", - " print(f\" GPU move failed ({e}), keeping on CPU.\")\n", - "\n", - " print(f\" - Loading Data from {DATA_FILE}...\")\n", - " with open(DATA_FILE, \"rb\") as f:\n", - " saved_data = pickle.load(f)\n", - " self.documents = saved_data[\"documents\"]\n", - " self.labeled_data = saved_data[\"labeled_data\"]\n", - " print(\"State restored.\")\n", - " return\n", - "\n", - " # --- OPTION B: BUILD FROM SCRATCH ---\n", - " print(\"\\nLoading Datasets from Hugging Face\")\n", - " ds_labeled = load_dataset(\"qiaojin/PubmedQA\", \"pqa_labeled\", split=\"train\")\n", - " ds_unlabeled = load_dataset(\"qiaojin/PubmedQA\", \"pqa_unlabeled\", split=\"train\")\n", - " ds_artificial = load_dataset(\"qiaojin/PubmedQA\", \"pqa_artificial\", split=\"train\")\n", - "\n", - " print(f\" - Labeled: {len(ds_labeled)}\")\n", - " print(f\" - Unlabeled: {len(ds_unlabeled)}\")\n", - " print(f\" - Artificial: {len(ds_artificial)}\")\n", - "\n", - " def process_split(dataset, split_name):\n", - " docs = []\n", - " for item in tqdm(dataset, desc=f\"Processing {split_name}\"):\n", - " full_text = \" \".join(item['context']['contexts'])\n", - " question_text = item.get('question', \"\")\n", - " if not question_text and split_name == \"labeled\":\n", - " question_text = item.get('question', \"No Question Found\")\n", - "\n", - " docs.append({\n", - " \"text\": full_text,\n", - " \"pubid\": item['pubid'],\n", - " \"question\": question_text,\n", - " \"final_decision\": item.get('final_decision', None)\n", - " })\n", - " return docs\n", - "\n", - " self.labeled_data = process_split(ds_labeled, \"labeled\")\n", - " all_docs = []\n", - " all_docs.extend(self.labeled_data)\n", - " all_docs.extend(process_split(ds_unlabeled, \"unlabeled\"))\n", - " all_docs.extend(process_split(ds_artificial, \"artificial\"))\n", - "\n", - " self.documents = all_docs\n", - " print(f\"Total Documents: {len(self.documents)}\")\n", - "\n", - " print(\"\\nGenerating Embeddings\")\n", - " texts = [d['text'] for d in self.documents]\n", - " embeddings = self.embedder.encode(\n", - " texts,\n", - " batch_size=BATCH_SIZE,\n", - " show_progress_bar=True,\n", - " convert_to_numpy=True,\n", - " normalize_embeddings=True\n", - " )\n", - "\n", - " print(f\"\\nBuilding FAISS {INDEX_TYPE} Index\")\n", - " index_flat = faiss.IndexFlatIP(self.embedding_dim)\n", - " index_flat.add(embeddings)\n", - "\n", - " # Save to disk\n", - " print(\"Saving to disk for future runs\")\n", - " faiss.write_index(index_flat, INDEX_FILE)\n", - " with open(DATA_FILE, \"wb\") as f:\n", - " pickle.dump({\"documents\": self.documents, \"labeled_data\": self.labeled_data}, f)\n", - "\n", - " # Enable GPU\n", - " if self.device == \"cuda\" and hasattr(faiss, \"StandardGpuResources\"):\n", - " try:\n", - " res = faiss.StandardGpuResources()\n", - " self.index = faiss.index_cpu_to_gpu(res, 0, index_flat)\n", - " except:\n", - " self.index = index_flat\n", - " else:\n", - " self.index = index_flat\n", - "\n", - " def retrieve(self, query, k=TOP_K_RETRIEVAL):\n", - " query_vec = self.embedder.encode([query], convert_to_numpy=True, normalize_embeddings=True)\n", - " distances, indices = self.index.search(query_vec, k)\n", - " results = []\n", - " for i, idx in enumerate(indices[0]):\n", - " if idx != -1:\n", - " results.append(self.documents[idx])\n", - " return results\n", - "\n", - " def generate_response(self, query, retrieved_docs, mode=\"detailed\"):\n", - " context_text = \"\\n\\n\".join([f\"Abstract {i+1}: {doc['text']}\" for i, doc in enumerate(retrieved_docs)])\n", - "\n", - " # 1. Define System & User Prompts\n", - " if mode == \"benchmark\":\n", - " sys_msg = (\n", - " \"Answer in just one word based on the given context.Do not provide any explanation. You final answer should be one of 3 words: yes, no, maybe\"\n", - " )\n", - " temp = 0.6\n", - " max_tokens = 2048\n", - " rep_penalty = 1.1\n", - "\n", - " else:\n", - " sys_msg = (\n", - " \"You are a helpful medical assistant. Answer the user's question based on the provided medical abstracts. \"\n", - " \"Cite the abstracts by number if necessary. Be concise.\"\n", - " )\n", - " temp = 0.6\n", - " max_tokens = 1024\n", - " rep_penalty = 1.1\n", - "\n", - " # 2. Create Chat Structure (Standard for Llama/DeepSeek)\n", - " messages = [\n", - " {\"role\": \"system\", \"content\": sys_msg},\n", - " {\"role\": \"user\", \"content\": f\"Contexts:\\n{context_text}\\n\\nQuestion: {query}\"}\n", - " ]\n", - "\n", - " # 3. Apply Chat Template (Handles special tokens like <|begin_of_text|>)\n", - " inputs = self.tokenizer.apply_chat_template(\n", - " messages,\n", - " tokenize=True,\n", - " add_generation_prompt=True,\n", - " return_tensors=\"pt\"\n", - " ).to(self.device)\n", - "\n", - " # 4. Generate\n", - " outputs = self.llm.generate(\n", - " inputs,\n", - " max_new_tokens=max_tokens,\n", - " temperature=temp,\n", - " top_p=1.0,\n", - " do_sample=False if mode==\"benchmark\" else True,\n", - " repetition_penalty=rep_penalty,\n", - ")\n", - "\n", - " # 5. Decode ONLY the new tokens (Slice off the prompt)\n", - " # This removes the need to manually split \"Question:...\" from the output\n", - " generated_tokens = outputs[0][len(inputs[0]):]\n", - " response = self.tokenizer.decode(generated_tokens, skip_special_tokens=False)\n", - "\n", - " # 6. Clean up DeepSeek tags\n", - " if \"\" in response:\n", - " response = response.split(\"\")[-1].strip()\n", - " #else:\n", - " # Fallback: specific regex if tags are still missing but reasoning is evident\n", - " #response = re.sub(r'.*?', '', response, flags=re.DOTALL).strip()\n", - "\n", - " # 7. Clean up \"Answer:\" prefix if present\n", - " if response.startswith(\"Answer:\"):\n", - " response = response[7:].strip()\n", - "\n", - " # 8. Final clean of EOS tokens\n", - " response = response.replace(\"\", \"\").replace(\"<|end_of_text|>\", \"\").replace(\"<|end_of_sentence|>\", \"\").replace(\"<๏ฝœendโ–ofโ–sentence๏ฝœ>\", \"\").strip()\n", - "\n", - " return response\n", - "\n", - "\n", - " def run_benchmark(self, sample_size=50):\n", - "\n", - " print(f\"\\nSTARTING BENCHMARK (Sample Size: {sample_size})...\")\n", - "\n", - " # Slice the test set\n", - " test_set = self.labeled_data[:sample_size]\n", - " y_true = []\n", - " y_pred = []\n", - "\n", - " start_time = time.time()\n", - "\n", - " for item in tqdm(test_set, desc=\"Benchmarking\"):\n", - " # Safety check for data integrity\n", - " question = item.get('question')\n", - " ground_truth = item.get('final_decision')\n", - "\n", - " if not question or not ground_truth:\n", - " continue\n", - "\n", - " # Retrieve and Generate\n", - " retrieved = self.retrieve(question, k=TOP_K_RETRIEVAL)\n", - " response = self.generate_response(question, retrieved, mode=\"benchmark\")\n", - "\n", - " # Normalize prediction\n", - " pred_lower = response.lower()\n", - " prediction = \"maybe\"\n", - " if \"yes\" in pred_lower:\n", - " prediction = \"yes\"\n", - " elif \"no\" in pred_lower:\n", - " prediction = \"no\"\n", - "\n", - " y_true.append(ground_truth)\n", - " y_pred.append(prediction)\n", - "\n", - " duration = time.time() - start_time\n", - "\n", - " print(\"\\n\" + \"=\"*50)\n", - " print(\"BENCHMARK RESULTS\")\n", - " print(\"=\"*50)\n", - " print(f\"Time taken: {duration:.2f}s\")\n", - " print(f\"Accuracy: {accuracy_score(y_true, y_pred):.2%}\")\n", - "\n", - " # 1. Classification Report (Existing)\n", - " labels = [\"yes\", \"no\", \"maybe\"]\n", - " print(\"\\n--- Classification Report ---\")\n", - " print(classification_report(y_true, y_pred, labels=labels, zero_division=0))\n", - "\n", - " # 2. Prediction Counts (New)\n", - " print(\"\\n--- Prediction Summary ---\")\n", - " pred_counts = pd.Series(y_pred).value_counts().reindex(labels, fill_value=0)\n", - " print(pred_counts)\n", - "\n", - " # 3. Confusion Matrix (New)\n", - " cm = confusion_matrix(y_true, y_pred, labels=labels)\n", - "\n", - " # Plotting the Matrix\n", - " plt.figure(figsize=(8, 6))\n", - " sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',\n", - " xticklabels=labels, yticklabels=labels)\n", - " plt.xlabel('Predicted')\n", - " plt.ylabel('Actual')\n", - " plt.title('Confusion Matrix')\n", - " plt.show()\n", - "\n", - " def launch_gradio_ui(self):\n", - " \"\"\"Launches the Gradio Chat Interface.\"\"\"\n", - " print(\"\\nLaunching Gradio UI\")\n", - "\n", - " def chat_logic(message, history):\n", - " # We ignore history for single-turn RAG to keep context clean and fast\n", - " retrieved = self.retrieve(message)\n", - " response = self.generate_response(message, retrieved, mode=\"interactive\")\n", - " return response\n", - "\n", - " demo = gr.ChatInterface(\n", - " fn=chat_logic,\n", - " title=\"PubMed Medical AI Assistant\",\n", - " description=\"Ask detailed medical questions. The AI retrieves relevant abstracts from the PubMedQA dataset to generate answers.\",\n", - " examples=[\n", - " \"Do preoperative statins reduce atrial fibrillation?\",\n", - " \"Is Hirschsprung disease a mendelian or a multifactorial disorder?\",\n", - " \"Does high-dose aspirin prevent cardiovascular events?\"\n", - " ],\n", - " theme=\"soft\"\n", - " )\n", - "\n", - " # share=True creates a public link\n", - " demo.launch(share=True, debug=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "rITTxoHN7cfw", - "outputId": "14a279a4-8c76-4bfd-ef9c-432d89212b79" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "GPU Memory Cleared. Current allocated: 0.00 MB\n" - ] - } - ], - "source": [ - "import gc\n", - "if 'rag_system' in globals():\n", - " del rag_system\n", - " print(\"Deleted rag_system object.\")\n", - "\n", - "if 'app' in globals():\n", - " del app\n", - " print(\"Deleted app object.\")\n", - "\n", - "# 2. Run Garbage Collector\n", - "gc.collect()\n", - "if torch.cuda.is_available():\n", - " torch.cuda.empty_cache()\n", - " torch.cuda.ipc_collect() # Clear IPC memory if using multiprocessing\n", - " print(f\"GPU Memory Cleared. Current allocated: {torch.cuda.memory_allocated() / 1024**2:.2f} MB\")\n", - "else:\n", - " print(\"No GPU detected.\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 833, - "referenced_widgets": [ - "4a0ea4434e584a36bdafbd77fdf915d4", - "9ffe20e0f2124c14b7c12ce8daf7c4b5", - "ea06f0dd061c4e3a96240433969ed793", - "c527b5e31da3403fb57d9fd4b2b9b191", - "76f4bb7b9b104810985282bd16842650", - "afa27f83547045aca2c4e8e92fe49878", - "871272ea241147bb87feeaaf793e1d9e", - "482e5a82919f4a1ab8168b0006b2d979", - "8ac89f580fb8427697cac9fad7ee1693", - "5dd09f0c273647bd89678aa2fe32f6df", - "e4ea1f9ea6a343ef995c2eface0efd13", - "c2c50ced649b428097bffe04f97a137f", - "38b4097af230467a81ee65454f907eb5", - "0b6e7094f94f4cb481e772bd5443a025", - "1d4b2d25edcd4207bce2972e824e815d", - "60574c59d6da4bfa92b4b93884d2b5d8", - "ab64037914e04ee7adb2877784271790", - "feeb9cc484ee467b851fb576af521adf", - "a4ba3264f68348eb9650d38fd1668a26", - "421cda2009074fcd9c5d4a81109b67ce", - "e2a80bf2c52d4a0c98731e3de6a24f22", - "b06f97d725d14843a55ff2442af9a97e", - "c2536ef6eb4341e3b54cf473ae498ca9", - "92a456546b7b493d8321c24a62a6b551", - "ea161cf843184232bb844545176cffb0", - "9a80ee6fd91a401dac1652acd15d44f5", - "3bd2d7fb9da04416b2bcca81d4d15bd5", - "c9d5bcb10e674784a570b625a59f12cf", - "e88678951fc2460ea8241edba800a2c0", - "bdf14d4a82c2425d8ec0c9a2acc63055", - "603d475c14724766b576206c2ec53a45", - "7f5af4598bb24c0b992424624f6dfb6d", - "74af2c01c93847bdb011d0368acf44f7", - "b316a24c01d341ca878ebc93bb6db8ad", - "77232511fed041feaeefc974aceee07f", - "1d67cff04e32401da2213ad82228b20d", - "774add6aa42e4884809cdf435ce39068", - "0f56e7ca761d45c5ab1345df35e81f9e", - "d0c8f23c7434465ab284741201956266", - "13a434a2a4624e6192ca51ae7e394667", - "fac8b597f40c43a29f69ce1e6f3a77f9", - "aad85d3f773545799449c1a6080e7302", - "a0d0c70f3f11437f9c99619436e7f077", - "f6821378164c4c2eb9458d1458ca58e1", - "e34578ea4acd4623ba900470212faa90", - "9eddcbf6d4cc4f7ca5ba92e7d2dfd6db", - "2b77339b62684a4785226247f3e5e85d", - "29fe6407ea524db591c8f3579a4f8410", - "e07c41df4bf447acae4a58ae3b0c0d73", - "61ff82cf14374a28a23b941d965027eb", - "fa92d691af6946568131ab68afffbf00", - "efde71ea0b7440acbb9f45fdb5678264", - "196df619acff47b4a6ec9f9ce1710dd1", - "41f98a688b284ab4b6ffa93c1703abf4", - "94471425ae534a29a6d3d86a79312039", - "1d7ed0749dae4c81bad40d13c43d7629", - "439dc3fac7864363a295684ae40dbb35", - "5fad1f69c31243bebf49515b52540989", - "d031a5cdcf8d4a3aa030c22f9bbb423b", - "4867ed9d8d294c239f589708056ed572", - "17ea216363d94349b98f267554d7aeae", - "035286955a1b4a83957eb326f6bb0bee", - "3dd72d49bb3548fca32d19eded946c2d", - "7d3ad593a3314885a4f213e4c89da0c4", - "7254e9e3c0a24d0eaa7908b8018e3f83", - "28a9db2641bf474b84eb193e3a5143be", - "6ac6e1708632488b9af0bbefb6542a02", - "e847e23b3dc64c3684d713e36a2d43e5", - "bb112f11bd27452fadf6e17f6f5f5022", - "81d8ade37ad944c2bf20d5d2bec8e94c", - "b4d7798f0f9e4d68bb10104c227c8236", - "45041ac463164980bb6437335bcf7edf", - "4696a263556c417489660ea5c6240551", - "f41affd88fb944ed8ac6cc632d2d7edc", - "a423c1bf1c4f47dcb3ca275f27f4b23b", - "dfdac9cdd0c546429604490fdecb22a7", - "db2458bb9a0a49d8b087a2b1ebd0864d", - "38dcc7d3f5c142c68e7d5c5f6b86087d", - "f5be3e18dfc24499ae25a2ddb496ae02", - "cd046088208841c0be5b5c78203b00b8", - "7d874d8f7e744ce191f0cdae0798e707", - "eb6ee46fe93a46489385ae7ee409665d", - "a10211fa0818443f89043174a09bee1a", - "c0765fdc3eb143d6880ff4e00141ce14", - "d5f205776c3f42ccaa9542f715b85748", - "b19cf7b7c453462ea0afb26e12f404f2", - "89f29ab2c0f84bdf812d34265d0319d2", - "889fb0ea3dc3479ba47569301abf1095", - "1ef175665a774f44896a1f938d318c3d", - "7d74b694cba0407f992bce853ce926a3", - "a15d8fd61eba4842b6be4c015cca167d", - "0e048e0c3d8045309f54e9da505dc91b", - "bffaec4d3d254433b55a82dec359bf2c", - "d5bc725f098447a6920aee7ff3e00a97", - "6583e5aadfca452c93f8f87ec2f97b32", - "94a70ea4f9f94d7dbfbda2f820bf7016", - "66d531e4e851439e9b66b7a9b2d285b1", - "49b1777da63c42e69be269a810493345", - "aa365a31e1964c5bb4298a227b23d66c", - "6c52dc5d7772478eac56b01ea54c8e19", - "6a88eb28c414476c86ca250e5df03db6", - "54b693f1fd55413e825bcf420fa43e6a", - "1feef1a0e35c475e964d9e598cf1b4c5", - "e5a8f5ebb26843cf92150b2f817b2dad", - "2fba292dcd994df7bfeb0e2bdf672b5d", - "b54500bcbde249ffa53ccd68c2105b7a", - "0206f0d4ca17415f8691d3125d380aff", - "0b57c423eedc41a287fefe85e1f63d7c", - "927fff0357d7428480d2bd936b21ae6f", - "8e48b73fd5c648eb9609a8aa9830d12f", - "1405d35083dc4c11a9ad396085c97a86", - "f73b129ccce84d9f8b09c85ea7e13a5d", - "0b2f7c9999d84d49afa93b78e67ef2fa", - "9951e4c31cf445049d9f14280c1b063e", - "937d0c8d494d40c98927ea97d39933a9", - "599727a146204539892e35143c16557b", - "18b23622d00442ca9e977b587b0c7399", - "3633835bfdc24858bf09a23ddb01a75c", - "a917cc0882cf4354bd0d0ca37b0f5e62", - "a6be0858867147f6b1c64b626d583a2c", - "890f5d59c2944577921d3f31529aeaf9", - "bb412ac42ad64c30934b9594bfad392b", - "cac61cb1e1d7406e9dc97aa868525dfe", - "b38d78293de747829603e05788a7482c", - "538567e28b9942808b2d7197c156fdb1", - "9a02b190859e49efaeb6d51605eee3b2", - "428e6cbcb8bc40bcb1b414667bb76b2a", - "3ea8fd43ac404f9bb6981c2a90ff1e89", - "a366ef49fb9e4e068c7206fa5de9a4d3", - "6824c16467c644af923b0f11ac97ddb7", - "638a3ecec14d45f399461d6b38ee81fd", - "c8a3b8286e3b4027b67758fdf25c2e35", - "9aee096b2c81404bb99d96fe337cee8f", - "248e6c55a0284f4c857aad39c860ec75", - "efba2394d02944e5927907b4bfc3ccee", - "0462deedee3648ae8aaa3ca3f2f1ef3a", - "8c4ba041dafb4b429a4f23a77efb4746", - "c0a319544cd34d56bfd4006624a97e4d", - "caea2c391cab4442a9ea8361cc7f8155", - "3a0263be005f445fbfcd5ad0c9519d91", - "0d437a74f6644ef3a5fe13a4e9d4b9e8", - "c4b1153f8e0e4f3aa43d38bd0fce633a", - "833dd5db9c8641fb8297fa3761d8aedf", - "6dfc2bf4e9424660a65a5eed380280f3", - "3fe726c210d24f67a6d07d4446a852e6", - "9efa8add1b4e4b79a542b40d4d24e613", - "dd9daff9f9f94764aeb9234e72ea6249", - "2c01b612ca0f4e259dcf092f49186446", - "db060cbdb85a4aa480a98c3784b95029", - "b62fe168ee7f4be4958f377409753e2a", - "2239b2fa9dc64255ac7dec5ce5228856", - "c564890b26554bd89876286934cc622e", - "4a8d56c5081342a1a5ea977f0e5e4903", - "1368e371c0e445e69601acb25f90e5a9", - "966b2b6f86dc49e7a579b500bef5b031", - "6925e5ccc9be44f6bc8477a974e75478", - "f8d10d68c78048e5ab8fc3caf3df6643", - "34a668472a424843bf5793ddcb449ec1", - "e2a296c76c4845fbba8070a639deddbd", - "405dd53da4354822a2a2c504a4def91d", - "cdd057a3ca3b4a18a9eabd1e267f4924", - "ccec33fd75964b30a96ce2e2a1e9815f", - "07cd5bdb960f4508b89a8367222ac494", - "a7150866ea154d5b856961cb6057b36a", - "257493a319dc4612b0f9fed2793913a9", - "146f8df9d3854b5b887412a8f61d66e0", - "986e6f7bc5264864ac8835058e949ad0", - "e30cfabacedb4a17a1f3953535d1712f", - "01c0eaf975ad4a8aa970f967e255981f", - "5e66b50859b04182b3cf604f09444c68", - "40e78669fb1c4c7dbd18601a3f2a3543", - "b8540d0b29c34a9ab6045b7276ed0ba4", - "3efb64c7f88a4db080cdb2255c8469fc", - "9e68a7867b4941109d73f86659f5335a", - "1a8107dc0d53406ba59789b98aff7f9a", - "9c79d2d8bd534bd982681128ff66155a", - "4e7174b5381546b98c0483631316f9e5", - "2f1d706566f146febfb49aa02a4fc7de", - "f5ebdd8a776542d3b7f9b476c0aa5512", - "2ae85a21b46a469cac1f9f7d482de268", - "3f59d1d5e6174d6894661efecdd33ab8", - "5e5995ebed594d9cad5621faa35f77a2", - "89cc23e9cf2847788ec3edf1ba2db8e3", - "665ef92b2901437b87fec93d13b2d4ac", - "b0035682525c443884fc1212f968b320", - "5992d99cf8bc4146983af1d8cd7b265d", - "62519dce485943e1b82fa59e251c1356", - "f6a54fc4dc2847659f547739c35a21b0", - "73e01096faf44ad9b74735371430b492", - "5acbc6e8cb7c4bb486e3db5b55aff134", - "0cc723ccf64a4b03bc743c1a564894b0", - "6fde41413a1a4839973593da2f78ced3", - "6831116bb8884a9480d7f3bfe9973d9a", - "a7e3a11dd3f94e4fb54037ae40aba206", - "3ff040d751764df1bd75e25f24d9942e", - "fb0f1fb808504ea294a729f05b1abcc1", - "0b8590fba60f40f6af3835446752cd37", - "9d32b2d2e5c64bc49f72f2226483cfa9", - "f8a4062f155d4750a30bfbdc5292ddea", - "f40afdca622848cfb153e6dc1c3f8d64", - "6d4efff530d54625a97583d6bed2520c", - "22ef9732885147e3983c606a466e0615", - "8a46241fb1a34bd1ac48677447aa2942", - "c6b666ea73f74788af455ec3eb739918", - "25c0858689be446d8403e09f4a61dbbc", - "5dc0cbd7081645b6bee0ac67da226f7f", - "182592a6f90940c488cfb93b88d11de8", - "7116032437674606b9f3966eea6457bd", - "ab3fe113b61548c0bc356114fc0c054e", - "411d3970835f4a40a916329318af5be4", - "79fd48757aac4ad0a24fefd6ea1d305b", - "ae1cb2179d3b4fcb92b168699d9bdabf", - "fad638c32e4a407b97e6b464c8b2070b", - "5503d335b4d649bea4fcb23e41279b0a", - "1c27ab84b04f4473b264c20a8429eba1", - "236f08564a1c434e9d265b9cbd601f0c", - "5c2a29cd2e5d4b1e9ace1b517f80247c", - "3be95f3a95854ba6b8f68ff4b63b6649", - "5dbf531a776c48728899c0c00ab9b8f1", - "c401a44341474936876919f32aa32ca4" - ] - }, - "id": "3teRQj_RZr2l", - "outputId": "19d120d2-89f6-49a6-8c72-52e3ca326485" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Initializing RAG Pipeline on cuda\n", - "Loading Embedding Model: sentence-transformers/all-mpnet-base-v2\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/lib/python3.12/dist-packages/huggingface_hub/utils/_auth.py:94: UserWarning: \n", - "The secret `HF_TOKEN` does not exist in your Colab secrets.\n", - "To authenticate with the Hugging Face Hub, create a token in your settings tab (https://huggingface.co/settings/tokens), set it as secret in your Google Colab and restart your session.\n", - "You will be able to reuse this secret in all of your notebooks.\n", - "Please note that authentication is recommended but still optional to access public models or datasets.\n", - " warnings.warn(\n" - ] - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "4a0ea4434e584a36bdafbd77fdf915d4", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "modules.json: 0%| | 0.00/349 [00:00" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# 3. Run Benchmark\n", - "rag_system.run_benchmark(sample_size=200)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 854 - }, - "id": "WyXrV0W_Zym1", - "outputId": "fe936e62-a0bb-4b42-abf5-e0cc24ecab29" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "Launching Gradio UI\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/lib/python3.12/dist-packages/gradio/chat_interface.py:347: UserWarning: The 'tuples' format for chatbot messages is deprecated and will be removed in a future version of Gradio. Please set type='messages' instead, which uses openai-style 'role' and 'content' keys.\n", - " self.chatbot = Chatbot(\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Colab notebook detected. This cell will run indefinitely so that you can see errors and logs. To turn off, set debug=False in launch().\n", - "* Running on public URL: https://c9f3b4c1ac2f5412a9.gradio.live\n", - "\n", - "This share link expires in 1 week. For free permanent hosting and GPU upgrades, run `gradio deploy` from the terminal in the working directory to deploy to Hugging Face Spaces (https://huggingface.co/spaces)\n" - ] - }, - { - "data": { - "text/html": [ - "
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "The attention mask and the pad token id were not set. As a consequence, you may observe unexpected behavior. Please pass your input's `attention_mask` to obtain reliable results.\n", - "Setting `pad_token_id` to `eos_token_id`:128001 for open-end generation.\n", - "The attention mask and the pad token id were not set. As a consequence, you may observe unexpected behavior. Please pass your input's `attention_mask` to obtain reliable results.\n", - "Setting `pad_token_id` to `eos_token_id`:128001 for open-end generation.\n", - "The attention mask and the pad token id were not set. As a consequence, you may observe unexpected behavior. Please pass your input's `attention_mask` to obtain reliable results.\n", - "Setting `pad_token_id` to `eos_token_id`:128001 for open-end generation.\n", - "The attention mask and the pad token id were not set. As a consequence, you may observe unexpected behavior. Please pass your input's `attention_mask` to obtain reliable results.\n", - "Setting `pad_token_id` to `eos_token_id`:128001 for open-end generation.\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Keyboard interruption in main thread... closing server.\n", - "Killing tunnel 127.0.0.1:7860 <> https://c9f3b4c1ac2f5412a9.gradio.live\n" - ] - } - ], - "source": [ - "# 4. Launch UI\n", - "rag_system.launch_gradio_ui()" - ] - } - ], - "metadata": { - "accelerator": "GPU", - "colab": { - "gpuType": "A100", - "machine_shape": "hm", - "provenance": [] - }, - "kernelspec": { - "display_name": "Python 3", - "name": "python3" - }, - "language_info": { - "name": "python" - }, - "widgets": { - "application/vnd.jupyter.widget-state+json": { - "01c0eaf975ad4a8aa970f967e255981f": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_1a8107dc0d53406ba59789b98aff7f9a", - "placeholder": "โ€‹", - "style": "IPY_MODEL_9c79d2d8bd534bd982681128ff66155a", - "value": "โ€‡2/2โ€‡[00:36<00:00,โ€‡36.46s/it]" - } - }, - "0206f0d4ca17415f8691d3125d380aff": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "035286955a1b4a83957eb326f6bb0bee": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "0407b8dd4aeb47b5b764bbff04602228": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "0462deedee3648ae8aaa3ca3f2f1ef3a": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_c4b1153f8e0e4f3aa43d38bd0fce633a", - "placeholder": "โ€‹", - "style": "IPY_MODEL_833dd5db9c8641fb8297fa3761d8aedf", - "value": "โ€‡9.08M/?โ€‡[00:00<00:00,โ€‡151MB/s]" - } - }, - "07cd5bdb960f4508b89a8367222ac494": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "0b2f7c9999d84d49afa93b78e67ef2fa": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_3633835bfdc24858bf09a23ddb01a75c", - "max": 190, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_a917cc0882cf4354bd0d0ca37b0f5e62", - "value": 190 - } - }, - "0b57c423eedc41a287fefe85e1f63d7c": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "0b6e7094f94f4cb481e772bd5443a025": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_a4ba3264f68348eb9650d38fd1668a26", - "max": 116, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_421cda2009074fcd9c5d4a81109b67ce", - "value": 116 - } - }, - "0b8590fba60f40f6af3835446752cd37": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "0cc723ccf64a4b03bc743c1a564894b0": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_0b8590fba60f40f6af3835446752cd37", - "placeholder": "โ€‹", - "style": "IPY_MODEL_9d32b2d2e5c64bc49f72f2226483cfa9", - "value": "โ€‡8.67G/8.67Gโ€‡[00:36<00:00,โ€‡518MB/s]" - } - }, - "0d437a74f6644ef3a5fe13a4e9d4b9e8": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "0e048e0c3d8045309f54e9da505dc91b": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_49b1777da63c42e69be269a810493345", - "placeholder": "โ€‹", - "style": "IPY_MODEL_aa365a31e1964c5bb4298a227b23d66c", - "value": "โ€‡466k/?โ€‡[00:00<00:00,โ€‡40.0MB/s]" - } - }, - "0f56e7ca761d45c5ab1345df35e81f9e": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "1081d141162244228d6d499183cf8739": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_58b03b1be4494a2ab597eee6bcf7ae54", - "IPY_MODEL_ce3396be9e2a410699c283ce39bf43d0", - "IPY_MODEL_35fdd3442b744bb18d190720cd3ee675" - ], - "layout": "IPY_MODEL_ed3439b4f7614a2e895aa6b8c68f0dd7" - } - }, - "1368e371c0e445e69601acb25f90e5a9": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "13a434a2a4624e6192ca51ae7e394667": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "1405d35083dc4c11a9ad396085c97a86": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_f73b129ccce84d9f8b09c85ea7e13a5d", - "IPY_MODEL_0b2f7c9999d84d49afa93b78e67ef2fa", - "IPY_MODEL_9951e4c31cf445049d9f14280c1b063e" - ], - "layout": "IPY_MODEL_937d0c8d494d40c98927ea97d39933a9" - } - }, - "146f8df9d3854b5b887412a8f61d66e0": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_986e6f7bc5264864ac8835058e949ad0", - "IPY_MODEL_e30cfabacedb4a17a1f3953535d1712f", - "IPY_MODEL_01c0eaf975ad4a8aa970f967e255981f" - ], - "layout": "IPY_MODEL_5e66b50859b04182b3cf604f09444c68" - } - }, - "17ea216363d94349b98f267554d7aeae": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "182592a6f90940c488cfb93b88d11de8": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "18b23622d00442ca9e977b587b0c7399": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "196df619acff47b4a6ec9f9ce1710dd1": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "1a8107dc0d53406ba59789b98aff7f9a": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "1c27ab84b04f4473b264c20a8429eba1": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "1d4b2d25edcd4207bce2972e824e815d": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_e2a80bf2c52d4a0c98731e3de6a24f22", - "placeholder": "โ€‹", - "style": "IPY_MODEL_b06f97d725d14843a55ff2442af9a97e", - "value": "โ€‡116/116โ€‡[00:00<00:00,โ€‡15.0kB/s]" - } - }, - "1d67cff04e32401da2213ad82228b20d": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_fac8b597f40c43a29f69ce1e6f3a77f9", - "max": 53, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_aad85d3f773545799449c1a6080e7302", - "value": 53 - } - }, - "1d7ed0749dae4c81bad40d13c43d7629": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_439dc3fac7864363a295684ae40dbb35", - "IPY_MODEL_5fad1f69c31243bebf49515b52540989", - "IPY_MODEL_d031a5cdcf8d4a3aa030c22f9bbb423b" - ], - "layout": "IPY_MODEL_4867ed9d8d294c239f589708056ed572" - } - }, - "1ef175665a774f44896a1f938d318c3d": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_7d74b694cba0407f992bce853ce926a3", - "IPY_MODEL_a15d8fd61eba4842b6be4c015cca167d", - "IPY_MODEL_0e048e0c3d8045309f54e9da505dc91b" - ], - "layout": "IPY_MODEL_bffaec4d3d254433b55a82dec359bf2c" - } - }, - "1feef1a0e35c475e964d9e598cf1b4c5": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_927fff0357d7428480d2bd936b21ae6f", - "placeholder": "โ€‹", - "style": "IPY_MODEL_8e48b73fd5c648eb9609a8aa9830d12f", - "value": "โ€‡239/239โ€‡[00:00<00:00,โ€‡26.4kB/s]" - } - }, - "2239b2fa9dc64255ac7dec5ce5228856": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "22ef9732885147e3983c606a466e0615": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_7116032437674606b9f3966eea6457bd", - "placeholder": "โ€‹", - "style": "IPY_MODEL_ab3fe113b61548c0bc356114fc0c054e", - "value": "โ€‡2/2โ€‡[00:17<00:00,โ€‡โ€‡8.55s/it]" - } - }, - "236f08564a1c434e9d265b9cbd601f0c": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "248e6c55a0284f4c857aad39c860ec75": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_c0a319544cd34d56bfd4006624a97e4d", - "placeholder": "โ€‹", - "style": "IPY_MODEL_caea2c391cab4442a9ea8361cc7f8155", - "value": "tokenizer.json:โ€‡" - } - }, - "257493a319dc4612b0f9fed2793913a9": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "25c0858689be446d8403e09f4a61dbbc": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "28a9db2641bf474b84eb193e3a5143be": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "29fe6407ea524db591c8f3579a4f8410": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_41f98a688b284ab4b6ffa93c1703abf4", - "placeholder": "โ€‹", - "style": "IPY_MODEL_94471425ae534a29a6d3d86a79312039", - "value": "โ€‡571/571โ€‡[00:00<00:00,โ€‡54.4kB/s]" - } - }, - "2ae85a21b46a469cac1f9f7d482de268": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_5992d99cf8bc4146983af1d8cd7b265d", - "placeholder": "โ€‹", - "style": "IPY_MODEL_62519dce485943e1b82fa59e251c1356", - "value": "โ€‡7.39G/7.39Gโ€‡[00:32<00:00,โ€‡57.2MB/s]" - } - }, - "2b77339b62684a4785226247f3e5e85d": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_efde71ea0b7440acbb9f45fdb5678264", - "max": 571, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_196df619acff47b4a6ec9f9ce1710dd1", - "value": 571 - } - }, - "2c01b612ca0f4e259dcf092f49186446": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "2f1d706566f146febfb49aa02a4fc7de": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_5e5995ebed594d9cad5621faa35f77a2", - "placeholder": "โ€‹", - "style": "IPY_MODEL_89cc23e9cf2847788ec3edf1ba2db8e3", - "value": "model-00002-of-000002.safetensors:โ€‡100%" - } - }, - "2fba292dcd994df7bfeb0e2bdf672b5d": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "31a0d00d72494794826148e1f442b848": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "34a668472a424843bf5793ddcb449ec1": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_a7150866ea154d5b856961cb6057b36a", - "placeholder": "โ€‹", - "style": "IPY_MODEL_257493a319dc4612b0f9fed2793913a9", - "value": "โ€‡24.2k/?โ€‡[00:00<00:00,โ€‡2.65MB/s]" - } - }, - "35fdd3442b744bb18d190720cd3ee675": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_8442e31b4f95484c84e74d7f85bf09ef", - "placeholder": "โ€‹", - "style": "IPY_MODEL_31a0d00d72494794826148e1f442b848", - "value": "โ€‡200/200โ€‡[1:15:56<00:00,โ€‡18.67s/it]" - } - }, - "3633835bfdc24858bf09a23ddb01a75c": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "38b4097af230467a81ee65454f907eb5": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_ab64037914e04ee7adb2877784271790", - "placeholder": "โ€‹", - "style": "IPY_MODEL_feeb9cc484ee467b851fb576af521adf", - "value": "config_sentence_transformers.json:โ€‡100%" - } - }, - "38dcc7d3f5c142c68e7d5c5f6b86087d": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_f5be3e18dfc24499ae25a2ddb496ae02", - "IPY_MODEL_cd046088208841c0be5b5c78203b00b8", - "IPY_MODEL_7d874d8f7e744ce191f0cdae0798e707" - ], - "layout": "IPY_MODEL_eb6ee46fe93a46489385ae7ee409665d" - } - }, - "3a0263be005f445fbfcd5ad0c9519d91": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": "20px" - } - }, - "3bd2d7fb9da04416b2bcca81d4d15bd5": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "3be95f3a95854ba6b8f68ff4b63b6649": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "3dd72d49bb3548fca32d19eded946c2d": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "3ea8fd43ac404f9bb6981c2a90ff1e89": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "3efb64c7f88a4db080cdb2255c8469fc": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "3f59d1d5e6174d6894661efecdd33ab8": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "3fe726c210d24f67a6d07d4446a852e6": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_db060cbdb85a4aa480a98c3784b95029", - "placeholder": "โ€‹", - "style": "IPY_MODEL_b62fe168ee7f4be4958f377409753e2a", - "value": "config.json:โ€‡100%" - } - }, - "3ff040d751764df1bd75e25f24d9942e": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "405dd53da4354822a2a2c504a4def91d": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "40e78669fb1c4c7dbd18601a3f2a3543": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "411d3970835f4a40a916329318af5be4": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_79fd48757aac4ad0a24fefd6ea1d305b", - "IPY_MODEL_ae1cb2179d3b4fcb92b168699d9bdabf", - "IPY_MODEL_fad638c32e4a407b97e6b464c8b2070b" - ], - "layout": "IPY_MODEL_5503d335b4d649bea4fcb23e41279b0a" - } - }, - "41f98a688b284ab4b6ffa93c1703abf4": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "421cda2009074fcd9c5d4a81109b67ce": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "428e6cbcb8bc40bcb1b414667bb76b2a": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "439dc3fac7864363a295684ae40dbb35": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_17ea216363d94349b98f267554d7aeae", - "placeholder": "โ€‹", - "style": "IPY_MODEL_035286955a1b4a83957eb326f6bb0bee", - "value": "model.safetensors:โ€‡100%" - } - }, - "45041ac463164980bb6437335bcf7edf": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "4696a263556c417489660ea5c6240551": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "482e5a82919f4a1ab8168b0006b2d979": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "4867ed9d8d294c239f589708056ed572": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "49b1777da63c42e69be269a810493345": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "4a0ea4434e584a36bdafbd77fdf915d4": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_9ffe20e0f2124c14b7c12ce8daf7c4b5", - "IPY_MODEL_ea06f0dd061c4e3a96240433969ed793", - "IPY_MODEL_c527b5e31da3403fb57d9fd4b2b9b191" - ], - "layout": "IPY_MODEL_76f4bb7b9b104810985282bd16842650" - } - }, - "4a8d56c5081342a1a5ea977f0e5e4903": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "4e7174b5381546b98c0483631316f9e5": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_2f1d706566f146febfb49aa02a4fc7de", - "IPY_MODEL_f5ebdd8a776542d3b7f9b476c0aa5512", - "IPY_MODEL_2ae85a21b46a469cac1f9f7d482de268" - ], - "layout": "IPY_MODEL_3f59d1d5e6174d6894661efecdd33ab8" - } - }, - "538567e28b9942808b2d7197c156fdb1": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_638a3ecec14d45f399461d6b38ee81fd", - "placeholder": "โ€‹", - "style": "IPY_MODEL_c8a3b8286e3b4027b67758fdf25c2e35", - "value": "โ€‡3.07k/?โ€‡[00:00<00:00,โ€‡408kB/s]" - } - }, - "54b693f1fd55413e825bcf420fa43e6a": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_0206f0d4ca17415f8691d3125d380aff", - "max": 239, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_0b57c423eedc41a287fefe85e1f63d7c", - "value": 239 - } - }, - "5503d335b4d649bea4fcb23e41279b0a": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "58b03b1be4494a2ab597eee6bcf7ae54": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_0407b8dd4aeb47b5b764bbff04602228", - "placeholder": "โ€‹", - "style": "IPY_MODEL_f1387e7b89c543beb5a20cd2cc13b4cc", - "value": "Benchmarking:โ€‡100%" - } - }, - "5992d99cf8bc4146983af1d8cd7b265d": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "599727a146204539892e35143c16557b": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "5acbc6e8cb7c4bb486e3db5b55aff134": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_3ff040d751764df1bd75e25f24d9942e", - "max": 8667826246, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_fb0f1fb808504ea294a729f05b1abcc1", - "value": 8667826246 - } - }, - "5c2a29cd2e5d4b1e9ace1b517f80247c": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "5dbf531a776c48728899c0c00ab9b8f1": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "5dc0cbd7081645b6bee0ac67da226f7f": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "5dd09f0c273647bd89678aa2fe32f6df": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "5e5995ebed594d9cad5621faa35f77a2": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "5e66b50859b04182b3cf604f09444c68": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "5fad1f69c31243bebf49515b52540989": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_3dd72d49bb3548fca32d19eded946c2d", - "max": 437971872, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_7d3ad593a3314885a4f213e4c89da0c4", - "value": 437971872 - } - }, - "603d475c14724766b576206c2ec53a45": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "60574c59d6da4bfa92b4b93884d2b5d8": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "61ff82cf14374a28a23b941d965027eb": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "62519dce485943e1b82fa59e251c1356": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "638a3ecec14d45f399461d6b38ee81fd": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "6583e5aadfca452c93f8f87ec2f97b32": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "665ef92b2901437b87fec93d13b2d4ac": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "66d531e4e851439e9b66b7a9b2d285b1": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "6824c16467c644af923b0f11ac97ddb7": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "6831116bb8884a9480d7f3bfe9973d9a": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "6925e5ccc9be44f6bc8477a974e75478": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_405dd53da4354822a2a2c504a4def91d", - "placeholder": "โ€‹", - "style": "IPY_MODEL_cdd057a3ca3b4a18a9eabd1e267f4924", - "value": "model.safetensors.index.json:โ€‡" - } - }, - "6a88eb28c414476c86ca250e5df03db6": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_2fba292dcd994df7bfeb0e2bdf672b5d", - "placeholder": "โ€‹", - "style": "IPY_MODEL_b54500bcbde249ffa53ccd68c2105b7a", - "value": "special_tokens_map.json:โ€‡100%" - } - }, - "6ac6e1708632488b9af0bbefb6542a02": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_e847e23b3dc64c3684d713e36a2d43e5", - "IPY_MODEL_bb112f11bd27452fadf6e17f6f5f5022", - "IPY_MODEL_81d8ade37ad944c2bf20d5d2bec8e94c" - ], - "layout": "IPY_MODEL_b4d7798f0f9e4d68bb10104c227c8236" - } - }, - "6c52dc5d7772478eac56b01ea54c8e19": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_6a88eb28c414476c86ca250e5df03db6", - "IPY_MODEL_54b693f1fd55413e825bcf420fa43e6a", - "IPY_MODEL_1feef1a0e35c475e964d9e598cf1b4c5" - ], - "layout": "IPY_MODEL_e5a8f5ebb26843cf92150b2f817b2dad" - } - }, - "6d4efff530d54625a97583d6bed2520c": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_5dc0cbd7081645b6bee0ac67da226f7f", - "max": 2, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_182592a6f90940c488cfb93b88d11de8", - "value": 2 - } - }, - "6dfc2bf4e9424660a65a5eed380280f3": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_3fe726c210d24f67a6d07d4446a852e6", - "IPY_MODEL_9efa8add1b4e4b79a542b40d4d24e613", - "IPY_MODEL_dd9daff9f9f94764aeb9234e72ea6249" - ], - "layout": "IPY_MODEL_2c01b612ca0f4e259dcf092f49186446" - } - }, - "6fde41413a1a4839973593da2f78ced3": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "7116032437674606b9f3966eea6457bd": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "7254e9e3c0a24d0eaa7908b8018e3f83": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "73e01096faf44ad9b74735371430b492": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_6831116bb8884a9480d7f3bfe9973d9a", - "placeholder": "โ€‹", - "style": "IPY_MODEL_a7e3a11dd3f94e4fb54037ae40aba206", - "value": "model-00001-of-000002.safetensors:โ€‡100%" - } - }, - "74af2c01c93847bdb011d0368acf44f7": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "76f4bb7b9b104810985282bd16842650": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "77232511fed041feaeefc974aceee07f": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_d0c8f23c7434465ab284741201956266", - "placeholder": "โ€‹", - "style": "IPY_MODEL_13a434a2a4624e6192ca51ae7e394667", - "value": "sentence_bert_config.json:โ€‡100%" - } - }, - "774add6aa42e4884809cdf435ce39068": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_a0d0c70f3f11437f9c99619436e7f077", - "placeholder": "โ€‹", - "style": "IPY_MODEL_f6821378164c4c2eb9458d1458ca58e1", - "value": "โ€‡53.0/53.0โ€‡[00:00<00:00,โ€‡7.44kB/s]" - } - }, - "79fd48757aac4ad0a24fefd6ea1d305b": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_1c27ab84b04f4473b264c20a8429eba1", - "placeholder": "โ€‹", - "style": "IPY_MODEL_236f08564a1c434e9d265b9cbd601f0c", - "value": "generation_config.json:โ€‡100%" - } - }, - "7d3ad593a3314885a4f213e4c89da0c4": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "7d74b694cba0407f992bce853ce926a3": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_d5bc725f098447a6920aee7ff3e00a97", - "placeholder": "โ€‹", - "style": "IPY_MODEL_6583e5aadfca452c93f8f87ec2f97b32", - "value": "tokenizer.json:โ€‡" - } - }, - "7d874d8f7e744ce191f0cdae0798e707": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_89f29ab2c0f84bdf812d34265d0319d2", - "placeholder": "โ€‹", - "style": "IPY_MODEL_889fb0ea3dc3479ba47569301abf1095", - "value": "โ€‡232k/?โ€‡[00:00<00:00,โ€‡16.1MB/s]" - } - }, - "7f5af4598bb24c0b992424624f6dfb6d": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "81d8ade37ad944c2bf20d5d2bec8e94c": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_dfdac9cdd0c546429604490fdecb22a7", - "placeholder": "โ€‹", - "style": "IPY_MODEL_db2458bb9a0a49d8b087a2b1ebd0864d", - "value": "โ€‡363/363โ€‡[00:00<00:00,โ€‡45.8kB/s]" - } - }, - "833dd5db9c8641fb8297fa3761d8aedf": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "8442e31b4f95484c84e74d7f85bf09ef": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "871272ea241147bb87feeaaf793e1d9e": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "889fb0ea3dc3479ba47569301abf1095": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "890f5d59c2944577921d3f31529aeaf9": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "89cc23e9cf2847788ec3edf1ba2db8e3": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "89f29ab2c0f84bdf812d34265d0319d2": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "8a46241fb1a34bd1ac48677447aa2942": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "8ac89f580fb8427697cac9fad7ee1693": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "8c4ba041dafb4b429a4f23a77efb4746": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "8e48b73fd5c648eb9609a8aa9830d12f": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "927fff0357d7428480d2bd936b21ae6f": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "92a456546b7b493d8321c24a62a6b551": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_c9d5bcb10e674784a570b625a59f12cf", - "placeholder": "โ€‹", - "style": "IPY_MODEL_e88678951fc2460ea8241edba800a2c0", - "value": "README.md:โ€‡" - } - }, - "937d0c8d494d40c98927ea97d39933a9": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "94471425ae534a29a6d3d86a79312039": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "94a70ea4f9f94d7dbfbda2f820bf7016": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": "20px" - } - }, - "966b2b6f86dc49e7a579b500bef5b031": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_6925e5ccc9be44f6bc8477a974e75478", - "IPY_MODEL_f8d10d68c78048e5ab8fc3caf3df6643", - "IPY_MODEL_34a668472a424843bf5793ddcb449ec1" - ], - "layout": "IPY_MODEL_e2a296c76c4845fbba8070a639deddbd" - } - }, - "986e6f7bc5264864ac8835058e949ad0": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_40e78669fb1c4c7dbd18601a3f2a3543", - "placeholder": "โ€‹", - "style": "IPY_MODEL_b8540d0b29c34a9ab6045b7276ed0ba4", - "value": "Fetchingโ€‡2โ€‡files:โ€‡100%" - } - }, - "9951e4c31cf445049d9f14280c1b063e": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_a6be0858867147f6b1c64b626d583a2c", - "placeholder": "โ€‹", - "style": "IPY_MODEL_890f5d59c2944577921d3f31529aeaf9", - "value": "โ€‡190/190โ€‡[00:00<00:00,โ€‡24.8kB/s]" - } - }, - "9a02b190859e49efaeb6d51605eee3b2": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "9a80ee6fd91a401dac1652acd15d44f5": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_7f5af4598bb24c0b992424624f6dfb6d", - "placeholder": "โ€‹", - "style": "IPY_MODEL_74af2c01c93847bdb011d0368acf44f7", - "value": "โ€‡11.6k/?โ€‡[00:00<00:00,โ€‡1.38MB/s]" - } - }, - "9aee096b2c81404bb99d96fe337cee8f": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_248e6c55a0284f4c857aad39c860ec75", - "IPY_MODEL_efba2394d02944e5927907b4bfc3ccee", - "IPY_MODEL_0462deedee3648ae8aaa3ca3f2f1ef3a" - ], - "layout": "IPY_MODEL_8c4ba041dafb4b429a4f23a77efb4746" - } - }, - "9c79d2d8bd534bd982681128ff66155a": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "9d32b2d2e5c64bc49f72f2226483cfa9": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "9e68a7867b4941109d73f86659f5335a": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "9eddcbf6d4cc4f7ca5ba92e7d2dfd6db": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_61ff82cf14374a28a23b941d965027eb", - "placeholder": "โ€‹", - "style": "IPY_MODEL_fa92d691af6946568131ab68afffbf00", - "value": "config.json:โ€‡100%" - } - }, - "9efa8add1b4e4b79a542b40d4d24e613": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_2239b2fa9dc64255ac7dec5ce5228856", - "max": 826, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_c564890b26554bd89876286934cc622e", - "value": 826 - } - }, - "9ffe20e0f2124c14b7c12ce8daf7c4b5": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_afa27f83547045aca2c4e8e92fe49878", - "placeholder": "โ€‹", - "style": "IPY_MODEL_871272ea241147bb87feeaaf793e1d9e", - "value": "modules.json:โ€‡100%" - } - }, - "a0d0c70f3f11437f9c99619436e7f077": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "a10211fa0818443f89043174a09bee1a": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "a15d8fd61eba4842b6be4c015cca167d": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_94a70ea4f9f94d7dbfbda2f820bf7016", - "max": 1, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_66d531e4e851439e9b66b7a9b2d285b1", - "value": 1 - } - }, - "a366ef49fb9e4e068c7206fa5de9a4d3": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": "20px" - } - }, - "a423c1bf1c4f47dcb3ca275f27f4b23b": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "a4ba3264f68348eb9650d38fd1668a26": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "a6be0858867147f6b1c64b626d583a2c": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "a7150866ea154d5b856961cb6057b36a": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "a7e3a11dd3f94e4fb54037ae40aba206": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "a917cc0882cf4354bd0d0ca37b0f5e62": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "a9a353632bc6456da4c6a6210cf2e7ae": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "aa365a31e1964c5bb4298a227b23d66c": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "aad85d3f773545799449c1a6080e7302": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "ab3fe113b61548c0bc356114fc0c054e": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "ab64037914e04ee7adb2877784271790": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "ae1cb2179d3b4fcb92b168699d9bdabf": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_5c2a29cd2e5d4b1e9ace1b517f80247c", - "max": 181, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_3be95f3a95854ba6b8f68ff4b63b6649", - "value": 181 - } - }, - "afa27f83547045aca2c4e8e92fe49878": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "b0035682525c443884fc1212f968b320": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "b06f97d725d14843a55ff2442af9a97e": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "b19cf7b7c453462ea0afb26e12f404f2": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "b316a24c01d341ca878ebc93bb6db8ad": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_77232511fed041feaeefc974aceee07f", - "IPY_MODEL_1d67cff04e32401da2213ad82228b20d", - "IPY_MODEL_774add6aa42e4884809cdf435ce39068" - ], - "layout": "IPY_MODEL_0f56e7ca761d45c5ab1345df35e81f9e" - } - }, - "b38d78293de747829603e05788a7482c": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_a366ef49fb9e4e068c7206fa5de9a4d3", - "max": 1, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_6824c16467c644af923b0f11ac97ddb7", - "value": 1 - } - }, - "b4d7798f0f9e4d68bb10104c227c8236": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "b54500bcbde249ffa53ccd68c2105b7a": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "b62fe168ee7f4be4958f377409753e2a": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "b8540d0b29c34a9ab6045b7276ed0ba4": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "bb112f11bd27452fadf6e17f6f5f5022": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_f41affd88fb944ed8ac6cc632d2d7edc", - "max": 363, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_a423c1bf1c4f47dcb3ca275f27f4b23b", - "value": 363 - } - }, - "bb412ac42ad64c30934b9594bfad392b": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_cac61cb1e1d7406e9dc97aa868525dfe", - "IPY_MODEL_b38d78293de747829603e05788a7482c", - "IPY_MODEL_538567e28b9942808b2d7197c156fdb1" - ], - "layout": "IPY_MODEL_9a02b190859e49efaeb6d51605eee3b2" - } - }, - "bdf14d4a82c2425d8ec0c9a2acc63055": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": "20px" - } - }, - "bffaec4d3d254433b55a82dec359bf2c": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "c0765fdc3eb143d6880ff4e00141ce14": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "c0a319544cd34d56bfd4006624a97e4d": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "c2536ef6eb4341e3b54cf473ae498ca9": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_92a456546b7b493d8321c24a62a6b551", - "IPY_MODEL_ea161cf843184232bb844545176cffb0", - "IPY_MODEL_9a80ee6fd91a401dac1652acd15d44f5" - ], - "layout": "IPY_MODEL_3bd2d7fb9da04416b2bcca81d4d15bd5" - } - }, - "c2c50ced649b428097bffe04f97a137f": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_38b4097af230467a81ee65454f907eb5", - "IPY_MODEL_0b6e7094f94f4cb481e772bd5443a025", - "IPY_MODEL_1d4b2d25edcd4207bce2972e824e815d" - ], - "layout": "IPY_MODEL_60574c59d6da4bfa92b4b93884d2b5d8" - } - }, - "c401a44341474936876919f32aa32ca4": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "c4b1153f8e0e4f3aa43d38bd0fce633a": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "c527b5e31da3403fb57d9fd4b2b9b191": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_5dd09f0c273647bd89678aa2fe32f6df", - "placeholder": "โ€‹", - "style": "IPY_MODEL_e4ea1f9ea6a343ef995c2eface0efd13", - "value": "โ€‡349/349โ€‡[00:00<00:00,โ€‡31.2kB/s]" - } - }, - "c564890b26554bd89876286934cc622e": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "c6b666ea73f74788af455ec3eb739918": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "c8a3b8286e3b4027b67758fdf25c2e35": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "c9d5bcb10e674784a570b625a59f12cf": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "cac61cb1e1d7406e9dc97aa868525dfe": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_428e6cbcb8bc40bcb1b414667bb76b2a", - "placeholder": "โ€‹", - "style": "IPY_MODEL_3ea8fd43ac404f9bb6981c2a90ff1e89", - "value": "tokenizer_config.json:โ€‡" - } - }, - "caea2c391cab4442a9ea8361cc7f8155": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "ccec33fd75964b30a96ce2e2a1e9815f": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": "20px" - } - }, - "cd046088208841c0be5b5c78203b00b8": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_d5f205776c3f42ccaa9542f715b85748", - "max": 1, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_b19cf7b7c453462ea0afb26e12f404f2", - "value": 1 - } - }, - "cdd057a3ca3b4a18a9eabd1e267f4924": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "ce3396be9e2a410699c283ce39bf43d0": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_fbe77566bd3448a7b83f76591c6de21f", - "max": 200, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_a9a353632bc6456da4c6a6210cf2e7ae", - "value": 200 - } - }, - "d031a5cdcf8d4a3aa030c22f9bbb423b": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_7254e9e3c0a24d0eaa7908b8018e3f83", - "placeholder": "โ€‹", - "style": "IPY_MODEL_28a9db2641bf474b84eb193e3a5143be", - "value": "โ€‡438M/438Mโ€‡[00:02<00:00,โ€‡415MB/s]" - } - }, - "d0c8f23c7434465ab284741201956266": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "d5bc725f098447a6920aee7ff3e00a97": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "d5f205776c3f42ccaa9542f715b85748": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": "20px" - } - }, - "db060cbdb85a4aa480a98c3784b95029": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "db2458bb9a0a49d8b087a2b1ebd0864d": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "dd9daff9f9f94764aeb9234e72ea6249": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_4a8d56c5081342a1a5ea977f0e5e4903", - "placeholder": "โ€‹", - "style": "IPY_MODEL_1368e371c0e445e69601acb25f90e5a9", - "value": "โ€‡826/826โ€‡[00:00<00:00,โ€‡78.4kB/s]" - } - }, - "dfdac9cdd0c546429604490fdecb22a7": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "e07c41df4bf447acae4a58ae3b0c0d73": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "e2a296c76c4845fbba8070a639deddbd": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "e2a80bf2c52d4a0c98731e3de6a24f22": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "e30cfabacedb4a17a1f3953535d1712f": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_3efb64c7f88a4db080cdb2255c8469fc", - "max": 2, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_9e68a7867b4941109d73f86659f5335a", - "value": 2 - } - }, - "e34578ea4acd4623ba900470212faa90": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_9eddcbf6d4cc4f7ca5ba92e7d2dfd6db", - "IPY_MODEL_2b77339b62684a4785226247f3e5e85d", - "IPY_MODEL_29fe6407ea524db591c8f3579a4f8410" - ], - "layout": "IPY_MODEL_e07c41df4bf447acae4a58ae3b0c0d73" - } - }, - "e4ea1f9ea6a343ef995c2eface0efd13": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "e5a8f5ebb26843cf92150b2f817b2dad": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "e847e23b3dc64c3684d713e36a2d43e5": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_45041ac463164980bb6437335bcf7edf", - "placeholder": "โ€‹", - "style": "IPY_MODEL_4696a263556c417489660ea5c6240551", - "value": "tokenizer_config.json:โ€‡100%" - } - }, - "e88678951fc2460ea8241edba800a2c0": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "ea06f0dd061c4e3a96240433969ed793": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_482e5a82919f4a1ab8168b0006b2d979", - "max": 349, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_8ac89f580fb8427697cac9fad7ee1693", - "value": 349 - } - }, - "ea161cf843184232bb844545176cffb0": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_bdf14d4a82c2425d8ec0c9a2acc63055", - "max": 1, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_603d475c14724766b576206c2ec53a45", - "value": 1 - } - }, - "eb6ee46fe93a46489385ae7ee409665d": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "ed3439b4f7614a2e895aa6b8c68f0dd7": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "efba2394d02944e5927907b4bfc3ccee": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_3a0263be005f445fbfcd5ad0c9519d91", - "max": 1, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_0d437a74f6644ef3a5fe13a4e9d4b9e8", - "value": 1 - } - }, - "efde71ea0b7440acbb9f45fdb5678264": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "f1387e7b89c543beb5a20cd2cc13b4cc": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "f40afdca622848cfb153e6dc1c3f8d64": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_c6b666ea73f74788af455ec3eb739918", - "placeholder": "โ€‹", - "style": "IPY_MODEL_25c0858689be446d8403e09f4a61dbbc", - "value": "Loadingโ€‡checkpointโ€‡shards:โ€‡100%" - } - }, - "f41affd88fb944ed8ac6cc632d2d7edc": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "f5be3e18dfc24499ae25a2ddb496ae02": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_a10211fa0818443f89043174a09bee1a", - "placeholder": "โ€‹", - "style": "IPY_MODEL_c0765fdc3eb143d6880ff4e00141ce14", - "value": "vocab.txt:โ€‡" - } - }, - "f5ebdd8a776542d3b7f9b476c0aa5512": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_665ef92b2901437b87fec93d13b2d4ac", - "max": 7392730108, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_b0035682525c443884fc1212f968b320", - "value": 7392730108 - } - }, - "f6821378164c4c2eb9458d1458ca58e1": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "f6a54fc4dc2847659f547739c35a21b0": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_73e01096faf44ad9b74735371430b492", - "IPY_MODEL_5acbc6e8cb7c4bb486e3db5b55aff134", - "IPY_MODEL_0cc723ccf64a4b03bc743c1a564894b0" - ], - "layout": "IPY_MODEL_6fde41413a1a4839973593da2f78ced3" - } - }, - "f73b129ccce84d9f8b09c85ea7e13a5d": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_599727a146204539892e35143c16557b", - "placeholder": "โ€‹", - "style": "IPY_MODEL_18b23622d00442ca9e977b587b0c7399", - "value": "config.json:โ€‡100%" - } - }, - "f8a4062f155d4750a30bfbdc5292ddea": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_f40afdca622848cfb153e6dc1c3f8d64", - "IPY_MODEL_6d4efff530d54625a97583d6bed2520c", - "IPY_MODEL_22ef9732885147e3983c606a466e0615" - ], - "layout": "IPY_MODEL_8a46241fb1a34bd1ac48677447aa2942" - } - }, - "f8d10d68c78048e5ab8fc3caf3df6643": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_ccec33fd75964b30a96ce2e2a1e9815f", - "max": 1, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_07cd5bdb960f4508b89a8367222ac494", - "value": 1 - } - }, - "fa92d691af6946568131ab68afffbf00": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "fac8b597f40c43a29f69ce1e6f3a77f9": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "fad638c32e4a407b97e6b464c8b2070b": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_5dbf531a776c48728899c0c00ab9b8f1", - "placeholder": "โ€‹", - "style": "IPY_MODEL_c401a44341474936876919f32aa32ca4", - "value": "โ€‡181/181โ€‡[00:00<00:00,โ€‡22.2kB/s]" - } - }, - "fb0f1fb808504ea294a729f05b1abcc1": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "fbe77566bd3448a7b83f76591c6de21f": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "feeb9cc484ee467b851fb576af521adf": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - } - } - } + "nbformat": 4, + "nbformat_minor": 5, + "metadata": { + "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}, + "language_info": {"name": "python", "version": "3.10.0"} + }, + "cells": [ + { + "cell_type": "markdown", "id": "md-title", "metadata": {}, + "source": [ + "# Plain RAG โ€” Flat FAISS Vector Baseline\n", + "\n", + "**Pipeline (the baseline that GraphRAG competes against):**\n", + "1. `all-MiniLM-L6-v2` encoding _(same model as GraphRAG)_\n", + "2. FAISS `IndexFlatIP` โ€” direct top-3 nearest-neighbour search, no reranking\n", + "3. Concatenated raw chunk texts as context (no graph expansion)\n", + "4. `deepseek-r1:8b` via Ollama _(same model as GraphRAG)_\n", + "\n", + "**Controlled variables** (identical in both notebooks): embedding model, LLM, system prompt, answer extraction, evaluation.\n", + "\n", + "Run on **Colab with GPU** โ€” change `faiss-cpu` to `faiss-gpu-cu12` in the install cell for ~40ร— faster index build." + ] }, - "nbformat": 4, - "nbformat_minor": 0 + { + "cell_type": "code", "execution_count": null, "id": "cell-install", "metadata": {}, "outputs": [], + "source": [ + "# On Colab with GPU: replace faiss-cpu with faiss-gpu-cu12\n", + "!pip install faiss-cpu sentence-transformers datasets gradio -q\n", + "# !pip install faiss-gpu-cu12 sentence-transformers datasets gradio -q\n", + "!curl -fsSL https://ollama.com/install.sh | sh" + ] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-imports", "metadata": {}, "outputs": [], + "source": [ + "import os, re, sys, time, json, pickle, subprocess, requests\n", + "import numpy as np\n", + "import faiss\n", + "import matplotlib.pyplot as plt\n", + "import seaborn as sns\n", + "from sklearn.metrics import accuracy_score, classification_report, confusion_matrix\n", + "from sentence_transformers import SentenceTransformer\n", + "from datasets import load_dataset\n", + "from tqdm import tqdm\n", + "import gradio as gr\n", + "\n", + "try:\n", + " import torch\n", + " HAS_CUDA = torch.cuda.is_available()\n", + "except ImportError:\n", + " HAS_CUDA = False\n", + "\n", + "print('Imports OK | GPU:', HAS_CUDA)" + ] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-shared", "metadata": {}, "outputs": [], + "source": [ + "# โ”€โ”€ Shared constants (word-for-word identical in GraphRAG.ipynb) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "EMBEDDING_MODEL = 'sentence-transformers/all-MiniLM-L6-v2'\n", + "LLM_MODEL = 'deepseek-r1:8b'\n", + "OLLAMA_API = 'http://localhost:11434/api/chat'\n", + "TOP_K_FINAL = 3\n", + "\n", + "BENCHMARK_SYSTEM_PROMPT = (\n", + " 'You are a PubMedQA annotator. Classify the answer as yes, no, or maybe.\\n\\n'\n", + " 'Guidelines:\\n'\n", + " '- YES : the study finds a positive outcome, correlation, or association,\\n'\n", + " ' even if further research is recommended.\\n'\n", + " '- NO : the study finds no significant difference or a negative result.\\n'\n", + " '- MAYBE: only if the abstract explicitly states inconclusive results\\n'\n", + " ' with no supporting data.\\n\\n'\n", + " 'End your response with exactly: Final Answer: [yes/no/maybe]'\n", + ")\n", + "\n", + "CHAT_SYSTEM_PROMPT = (\n", + " 'You are a helpful medical AI assistant. '\n", + " 'Use the provided research abstracts to answer the user question. '\n", + " 'Cite specific study titles when making claims. '\n", + " 'If studies conflict, explain the conflict. '\n", + " 'If the context is insufficient, say so and give your best assessment.'\n", + ")\n", + "\n", + "\n", + "class FuzzyEvaluator:\n", + " \"\"\"Extracts and normalises yes/no/maybe from verbose model output.\"\"\"\n", + " def extract_answer(self, text):\n", + " clean = re.sub(r'.*?', '', text, flags=re.DOTALL).lower()\n", + " m = re.search(r'final answer\\s*:\\s*(yes|no|maybe)', clean)\n", + " if m: return m.group(1)\n", + " hits = re.findall(r'\\b(yes|no|maybe)\\b', clean)\n", + " return hits[-1] if hits else 'maybe'\n", + "\n", + "\n", + "class Evaluator:\n", + " \"\"\"Records predictions and generates a full evaluation report.\"\"\"\n", + " def __init__(self, model_name):\n", + " self.model_name = model_name\n", + " self.y_true, self.y_pred, self.latencies = [], [], []\n", + "\n", + " def record(self, gt, pred, latency=0.0):\n", + " p = pred.lower().strip()\n", + " if p not in ('yes', 'no', 'maybe'): p = 'maybe'\n", + " self.y_true.append(gt.lower().strip())\n", + " self.y_pred.append(p)\n", + " self.latencies.append(latency)\n", + "\n", + " def report(self):\n", + " if not self.y_true: print('No data.'); return {}\n", + " labels = ['yes', 'no', 'maybe']\n", + " acc = accuracy_score(self.y_true, self.y_pred)\n", + " total = sum(self.latencies)\n", + " avg = total / len(self.latencies)\n", + " print(f\"\\n{'='*52}\\n {self.model_name} โ€” Evaluation Report\\n{'='*52}\")\n", + " print(f' Samples : {len(self.y_true)} | Accuracy : {acc:.2%} | Avg latency : {avg:.1f}s')\n", + " print(f\"{'โ”€'*52}\")\n", + " print(classification_report(self.y_true, self.y_pred, labels=labels, zero_division=0))\n", + " cm = confusion_matrix(self.y_true, self.y_pred, labels=labels)\n", + " fig, ax = plt.subplots(figsize=(6, 5))\n", + " sns.heatmap(cm, annot=True, fmt='d', cmap='Oranges',\n", + " xticklabels=labels, yticklabels=labels, ax=ax)\n", + " ax.set_xlabel('Predicted'); ax.set_ylabel('Actual')\n", + " ax.set_title(f'Confusion Matrix โ€” {self.model_name}')\n", + " plt.tight_layout(); plt.show()\n", + " return {'model': self.model_name, 'accuracy': acc, 'samples': len(self.y_true),\n", + " 'total_time': total, 'avg_latency': avg,\n", + " 'y_true': self.y_true, 'y_pred': self.y_pred}\n", + "\n", + " def save(self, path):\n", + " data = {'model': self.model_name,\n", + " 'accuracy': accuracy_score(self.y_true, self.y_pred) if self.y_true else 0,\n", + " 'samples': len(self.y_true), 'total_time': sum(self.latencies),\n", + " 'avg_latency': sum(self.latencies)/len(self.latencies) if self.latencies else 0,\n", + " 'y_true': self.y_true, 'y_pred': self.y_pred}\n", + " with open(path, 'w') as f: json.dump(data, f, indent=2)\n", + " print(f'Results saved to {path}')\n", + "\n", + "\n", + "def call_ollama(prompt, system='', temperature=0.0, model=LLM_MODEL):\n", + " messages = []\n", + " if system: messages.append({'role': 'system', 'content': system})\n", + " messages.append({'role': 'user', 'content': prompt})\n", + " payload = {'model': model, 'messages': messages, 'stream': False,\n", + " 'options': {'temperature': temperature, 'num_ctx': 4096}}\n", + " resp = requests.post(OLLAMA_API, json=payload, timeout=300)\n", + " resp.raise_for_status()\n", + " return resp.json()['message']['content']\n", + "\n", + "\n", + "print('Shared utilities ready.')" + ] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-config", "metadata": {}, "outputs": [], + "source": [ + "# โ”€โ”€ Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "INDEX_FILE = 'pubmed_rag_index.bin'\n", + "DATA_FILE = 'pubmed_rag_data.pkl'\n", + "RESULTS_DIR = '../results'\n", + "RESULTS_FILE = os.path.join(RESULTS_DIR, 'plainrag_results.json')\n", + "BENCHMARK_N = 100 # must match GraphRAG.ipynb BENCHMARK_N for a fair comparison\n", + "\n", + "os.makedirs(RESULTS_DIR, exist_ok=True)\n", + "print(f'Config ready. Results -> {RESULTS_FILE}')" + ] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-ollama", "metadata": {}, "outputs": [], + "source": [ + "def ensure_ollama(model=LLM_MODEL):\n", + " subprocess.Popen(['ollama', 'serve'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n", + " time.sleep(3)\n", + " result = subprocess.run(['ollama', 'list'], capture_output=True, text=True)\n", + " if model in result.stdout:\n", + " print(f'[Ollama] {model} is ready.')\n", + " return\n", + " print(f'[Ollama] Pulling {model} (~5 GB, may take a few minutes)...')\n", + " subprocess.run(['ollama', 'pull', model], check=True)\n", + " print(f'[Ollama] {model} ready.')\n", + "\n", + "ensure_ollama()" + ] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-plainrag-class", "metadata": {}, "outputs": [], + "source": [ + "class PlainRAG:\n", + " \"\"\"\n", + " Flat FAISS retrieval baseline. No graph, no reranking.\n", + "\n", + " Deliberate differences from GraphRAG:\n", + " - No wide candidate pool or CrossEncoder reranking\n", + " - No AQL graph traversal โ€” context is raw concatenated chunk text\n", + " - FAISS IndexFlatIP (not ArangoDB vector search)\n", + "\n", + " LLM, embedding model, prompt, and evaluation are identical to GraphRAG.\n", + " \"\"\"\n", + "\n", + " def __init__(self):\n", + " device = 'cuda' if HAS_CUDA else 'cpu'\n", + " print(f'[PlainRAG] Initialising on {device}')\n", + " print('[Model] Loading Sentence Transformer...')\n", + " self.encoder = SentenceTransformer(EMBEDDING_MODEL, device=device)\n", + " self.dim = self.encoder.get_sentence_embedding_dimension()\n", + " self.index = None\n", + " self.texts = []\n", + " print('[PlainRAG] Initialised.')\n", + "\n", + " def load_data(self, index_file=INDEX_FILE, data_file=DATA_FILE):\n", + " if os.path.exists(index_file) and os.path.exists(data_file):\n", + " print('[Index] Loading cached FAISS index...')\n", + " idx_cpu = faiss.read_index(index_file)\n", + " if HAS_CUDA and hasattr(faiss, 'StandardGpuResources'):\n", + " try:\n", + " res = faiss.StandardGpuResources()\n", + " self.index = faiss.index_cpu_to_gpu(res, 0, idx_cpu)\n", + " print('[Index] Moved to GPU.')\n", + " except Exception as e:\n", + " print(f'[Index] GPU failed ({e}), using CPU.')\n", + " self.index = idx_cpu\n", + " else:\n", + " self.index = idx_cpu\n", + " with open(data_file, 'rb') as f: self.texts = pickle.load(f)\n", + " print(f'[Index] Loaded {len(self.texts):,} docs.')\n", + " return\n", + "\n", + " # Build from pqa_unlabeled (61k docs) + pqa_artificial (211k)\n", + " # On Colab T4: ~5 min total. On CPU: 3+ hours.\n", + " print('[Index] Building FAISS index โ€” ~5 min on GPU, 3+ hours on CPU.')\n", + " texts = []\n", + " for split in ['pqa_unlabeled', 'pqa_artificial']:\n", + " ds = load_dataset('qiaojin/PubMedQA', split, split='train')\n", + " for item in tqdm(ds, desc=f'Loading {split}'):\n", + " ctx = item.get('context', {})\n", + " parts = ctx.get('contexts', [])\n", + " text = ' '.join(parts) if parts else item.get('question', '')\n", + " texts.append(text[:2048])\n", + "\n", + " idx_cpu = faiss.IndexFlatIP(self.dim)\n", + " for start in tqdm(range(0, len(texts), 256), desc='Embedding'):\n", + " batch = texts[start:start + 256]\n", + " embs = self.encoder.encode(batch, normalize_embeddings=True,\n", + " show_progress_bar=False, convert_to_numpy=True)\n", + " idx_cpu.add(np.array(embs, dtype=np.float32))\n", + "\n", + " self.texts = texts\n", + " faiss.write_index(idx_cpu, index_file)\n", + " with open(data_file, 'wb') as f: pickle.dump(texts, f)\n", + " print(f'[Index] Built and saved {len(texts):,} docs.')\n", + "\n", + " if HAS_CUDA and hasattr(faiss, 'StandardGpuResources'):\n", + " try:\n", + " res = faiss.StandardGpuResources()\n", + " self.index = faiss.index_cpu_to_gpu(res, 0, idx_cpu)\n", + " except Exception:\n", + " self.index = idx_cpu\n", + " else:\n", + " self.index = idx_cpu\n", + "\n", + " def retrieve(self, query):\n", + " if self.index is None or self.index.ntotal == 0: return 'No context available.'\n", + " q_emb = self.encoder.encode([query], normalize_embeddings=True, convert_to_numpy=True)\n", + " _, idx = self.index.search(np.array(q_emb, dtype=np.float32), TOP_K_FINAL)\n", + " return '\\n\\n'.join(\n", + " f'Abstract {i+1}: {self.texts[j]}' for i, j in enumerate(idx[0]) if j >= 0\n", + " )\n", + "\n", + " def answer_benchmark(self, question):\n", + " ctx = self.retrieve(question)\n", + " return call_ollama('Context:\\n' + ctx + '\\n\\nQuestion: ' + question,\n", + " system=BENCHMARK_SYSTEM_PROMPT, temperature=0.0)\n", + "\n", + " def answer_chat(self, question):\n", + " ctx = self.retrieve(question)\n", + " return call_ollama('Context:\\n' + ctx + '\\n\\nQuestion: ' + question,\n", + " system=CHAT_SYSTEM_PROMPT, temperature=0.3)" + ] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-init", "metadata": {}, "outputs": [], + "source": [ + "rag = PlainRAG()\n", + "rag.load_data()" + ] + }, + { + "cell_type": "markdown", "id": "md-benchmark", "metadata": {}, + "source": [ + "## Benchmark Evaluation\n", + "Evaluates on `pqa_labeled` (1 000 ground-truth samples). Adjust `BENCHMARK_N` in the config cell.\n", + "\n", + "Uses the same `FuzzyEvaluator` and `Evaluator` as `GraphRAG.ipynb` โ€” controlled comparison." + ] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-benchmark", "metadata": {}, "outputs": [], + "source": [ + "dataset = load_dataset('qiaojin/PubMedQA', 'pqa_labeled', split='train')\n", + "fuzzy = FuzzyEvaluator()\n", + "evaluator = Evaluator('Plain RAG')\n", + "\n", + "print(f'=== Plain RAG Benchmark (n={BENCHMARK_N}) ===')\n", + "for i, item in enumerate(dataset):\n", + " if i >= BENCHMARK_N: break\n", + " t0 = time.time()\n", + " raw = rag.answer_benchmark(item['question'])\n", + " lat = time.time() - t0\n", + " pred = fuzzy.extract_answer(raw)\n", + " gt = item['final_decision']\n", + " evaluator.record(gt, pred, lat)\n", + " print(f'[{i+1:3d}] GT={gt:<5} Pred={pred:<5} {\"v\" if pred==gt else \"x\"} ({lat:.1f}s)')\n", + "\n", + "results = evaluator.report()" + ] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-save", "metadata": {}, "outputs": [], + "source": ["evaluator.save(RESULTS_FILE)"] + }, + { + "cell_type": "markdown", "id": "md-compare", "metadata": {}, + "source": ["## Head-to-Head Comparison\n", "Run after both notebooks have saved results. Also available as a standalone `Comparison.ipynb`."] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-compare", "metadata": {}, "outputs": [], + "source": [ + "from sklearn.metrics import f1_score\n", + "\n", + "graphrag_path = os.path.join(RESULTS_DIR, 'graphrag_results.json')\n", + "if not os.path.exists(graphrag_path):\n", + " print('Run GraphRAG.ipynb first, then re-run this cell.')\n", + "else:\n", + " with open(graphrag_path) as f: gr_r = json.load(f)\n", + " with open(RESULTS_FILE) as f: pr_r = json.load(f)\n", + " LABELS = ['yes', 'no', 'maybe']\n", + " models = [gr_r['model'], pr_r['model']]\n", + " accs = [gr_r['accuracy']*100, pr_r['accuracy']*100]\n", + " f1s = [f1_score(r['y_true'], r['y_pred'], labels=LABELS, average='macro', zero_division=0)*100\n", + " for r in [gr_r, pr_r]]\n", + " lats = [gr_r['avg_latency'], pr_r['avg_latency']]\n", + " cols = ['#2196F3', '#FF9800']\n", + "\n", + " fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n", + " for ax, vals, lbl in zip(axes, [accs, f1s, lats],\n", + " ['Accuracy (%)', 'Macro F1 (%)', 'Avg Latency (s)']):\n", + " bars = ax.bar(models, vals, color=cols, width=0.4, edgecolor='white')\n", + " ax.set_title(lbl); ax.set_ylabel(lbl)\n", + " if 'Latency' not in lbl: ax.set_ylim(0, 100)\n", + " for b, v in zip(bars, vals):\n", + " ax.text(b.get_x()+b.get_width()/2, v*1.03 if 'Lat' in lbl else v+1.5,\n", + " f'{v:.1f}', ha='center', fontweight='bold')\n", + " plt.suptitle(f'GraphRAG vs Plain RAG (n={gr_r[\"samples\"]} samples)',\n", + " fontsize=13, fontweight='bold', y=1.02)\n", + " plt.tight_layout(); plt.show()\n", + "\n", + " delta = accs[0] - accs[1]\n", + " winner = models[0] if delta >= 0 else models[1]\n", + " print(f'\\n{winner} wins by {abs(delta):.1f} pp (accuracy delta: {delta:+.1f} pp)')" + ] + }, + { + "cell_type": "markdown", "id": "md-ui", "metadata": {}, + "source": ["## Interactive Chat UI"] + }, + { + "cell_type": "code", "execution_count": null, "id": "cell-ui", "metadata": {}, "outputs": [], + "source": [ + "def launch_ui(rag_instance):\n", + " def chat_fn(message, history): return rag_instance.answer_chat(message)\n", + " gr.ChatInterface(\n", + " fn=chat_fn, title='PubMed Plain RAG Assistant',\n", + " description='Flat FAISS retrieval: direct top-3 nearest-neighbour search over chunk embeddings.',\n", + " examples=['Do preoperative statins reduce atrial fibrillation?',\n", + " 'Is obesity a risk factor for cirrhosis-related death?',\n", + " 'Does high-dose aspirin prevent cardiovascular events?'],\n", + " theme='soft',\n", + " ).launch(share=True, debug=True)\n", + "\n", + "launch_ui(rag)" + ] + } + ] } diff --git a/run_comparison.py b/run_comparison.py new file mode 100644 index 0000000..f08be3f --- /dev/null +++ b/run_comparison.py @@ -0,0 +1,127 @@ +""" +Comparison script โ€” loads both result JSON files and prints/plots the comparison. +Run after run_plainrag.py and run_graphrag.py have both completed. +""" + +import os +import sys +import json +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import seaborn as sns +from sklearn.metrics import f1_score, confusion_matrix + +ROOT = os.path.dirname(os.path.abspath(__file__)) +RESULTS_DIR = os.path.join(ROOT, 'results') +GRAPHRAG_F = os.path.join(RESULTS_DIR, 'graphrag_results.json') +PLAINRAG_F = os.path.join(RESULTS_DIR, 'plainrag_results.json') +LABELS = ['yes', 'no', 'maybe'] + +for path in [GRAPHRAG_F, PLAINRAG_F]: + if not os.path.exists(path): + print(f'Missing: {path}') + print('Run run_graphrag.py and run_plainrag.py first.') + sys.exit(1) + +with open(GRAPHRAG_F) as f: + gr = json.load(f) +with open(PLAINRAG_F) as f: + pr = json.load(f) + +# โ”€โ”€ Summary table โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +gr_f1 = f1_score(gr['y_true'], gr['y_pred'], labels=LABELS, average='macro', zero_division=0) +pr_f1 = f1_score(pr['y_true'], pr['y_pred'], labels=LABELS, average='macro', zero_division=0) + +summary = pd.DataFrame({ + 'Model': [gr['model'], pr['model']], + 'Accuracy (%)': [round(gr['accuracy'] * 100, 2), round(pr['accuracy'] * 100, 2)], + 'Macro F1 (%)': [round(gr_f1 * 100, 2), round(pr_f1 * 100, 2)], + 'Avg Latency (s)': [round(gr['avg_latency'], 2), round(pr['avg_latency'], 2)], + 'Samples': [gr['samples'], pr['samples']], +}) +print('\n' + '=' * 60) +print(' RESULTS SUMMARY') +print('=' * 60) +print(summary.to_string(index=False)) +print('=' * 60) + +# โ”€โ”€ Accuracy / F1 / Latency bars โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +models = [gr['model'], pr['model']] +accs = [gr['accuracy'] * 100, pr['accuracy'] * 100] +f1s = [gr_f1 * 100, pr_f1 * 100] +lats = [gr['avg_latency'], pr['avg_latency']] +colours = ['#2196F3', '#FF9800'] + +fig, axes = plt.subplots(1, 3, figsize=(15, 5)) + +for ax, values, label in zip(axes, [accs, f1s, lats], ['Accuracy (%)', 'Macro F1 (%)', 'Avg Latency (s)']): + bars = ax.bar(models, values, color=colours, width=0.4, edgecolor='white') + ax.set_title(label) + ax.set_ylabel(label) + if 'Latency' not in label: + ax.set_ylim(0, 100) + for bar, val in zip(bars, values): + suffix = 's' if 'Latency' in label else ('%' if '%' in label else '') + ax.text(bar.get_x() + bar.get_width() / 2, + val * 1.03 if 'Latency' in label else val + 1.5, + f'{val:.1f}{suffix}', ha='center', fontweight='bold') + +plt.suptitle( + f'GraphRAG vs Plain RAG โ€” Head-to-Head (n={gr["samples"]} samples)', + fontsize=13, fontweight='bold', y=1.02, +) +plt.tight_layout() +plt.savefig(os.path.join(RESULTS_DIR, 'comparison_bars.png'), dpi=150, bbox_inches='tight') +plt.show() +print(f'Saved: {os.path.join(RESULTS_DIR, "comparison_bars.png")}') + +# โ”€โ”€ Confusion matrices โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +fig, axes = plt.subplots(1, 2, figsize=(13, 5)) +for ax, res, cmap in zip(axes, [gr, pr], ['Blues', 'Oranges']): + cm = confusion_matrix(res['y_true'], res['y_pred'], labels=LABELS) + sns.heatmap(cm, annot=True, fmt='d', cmap=cmap, + xticklabels=LABELS, yticklabels=LABELS, ax=ax) + ax.set_xlabel('Predicted') + ax.set_ylabel('Actual') + ax.set_title(res['model'] + f' (acc={res["accuracy"]:.2%})') + +plt.suptitle('Confusion Matrices', fontsize=13, fontweight='bold') +plt.tight_layout() +plt.savefig(os.path.join(RESULTS_DIR, 'comparison_confusion.png'), dpi=150, bbox_inches='tight') +plt.show() +print(f'Saved: {os.path.join(RESULTS_DIR, "comparison_confusion.png")}') + +# โ”€โ”€ Per-class F1 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +gr_f1s = f1_score(gr['y_true'], gr['y_pred'], labels=LABELS, average=None, zero_division=0) +pr_f1s = f1_score(pr['y_true'], pr['y_pred'], labels=LABELS, average=None, zero_division=0) + +x, width = np.arange(len(LABELS)), 0.35 +fig, ax = plt.subplots(figsize=(9, 5)) +b1 = ax.bar(x - width / 2, gr_f1s * 100, width, label=gr['model'], color='#2196F3', edgecolor='white') +b2 = ax.bar(x + width / 2, pr_f1s * 100, width, label=pr['model'], color='#FF9800', edgecolor='white') +ax.set_xticks(x); ax.set_xticklabels(LABELS) +ax.set_ylabel('F1 Score (%)'); ax.set_ylim(0, 100) +ax.set_title('Per-class F1 Score'); ax.legend() +for bar in list(b1) + list(b2): + h = bar.get_height() + ax.text(bar.get_x() + bar.get_width() / 2, h + 1, f'{h:.1f}', ha='center', fontsize=9) +plt.tight_layout() +plt.savefig(os.path.join(RESULTS_DIR, 'comparison_f1.png'), dpi=150, bbox_inches='tight') +plt.show() +print(f'Saved: {os.path.join(RESULTS_DIR, "comparison_f1.png")}') + +# โ”€โ”€ Verdict โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +acc_delta = (gr['accuracy'] - pr['accuracy']) * 100 +f1_delta = (gr_f1 - pr_f1) * 100 +lat_delta = gr['avg_latency'] - pr['avg_latency'] +winner = gr['model'] if acc_delta >= 0 else pr['model'] + +print('\n' + '=' * 60) +print(' VERDICT') +print('=' * 60) +print(f' Winner : {winner}') +print(f' Accuracy delta : {acc_delta:+.2f} pp (GraphRAG โˆ’ Plain RAG)') +print(f' Macro F1 delta : {f1_delta:+.2f} pp') +print(f' Latency delta : {lat_delta:+.2f}s') +print('=' * 60) diff --git a/run_graphrag.py b/run_graphrag.py new file mode 100644 index 0000000..16e091a --- /dev/null +++ b/run_graphrag.py @@ -0,0 +1,242 @@ +""" +GraphRAG benchmark runner โ€” standalone script (no Jupyter required). +Runs the same pipeline as GraphRAG.ipynb. + +Set ARANGO_PASS before running: + Windows: $env:ARANGO_PASS = "your_password" + Linux: export ARANGO_PASS=your_password +""" + +import os +import sys +import time +import subprocess + +# Add repo root to path so shared_utils is importable +ROOT = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, ROOT) + +# โ”€โ”€ Start Ollama (idempotent) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +print('[Ollama] Starting server...') +subprocess.Popen( + ['ollama', 'serve'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, +) +time.sleep(3) + +# โ”€โ”€ Imports โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +import pickle +import requests +import numpy as np +from arango import ArangoClient +from arango.exceptions import ServerConnectionError, ArangoServerError +from datasets import load_dataset +from sentence_transformers import SentenceTransformer, CrossEncoder +from sklearn.metrics.pairwise import cosine_similarity +from tqdm import tqdm + +from shared_utils import ( + EMBEDDING_MODEL, LLM_MODEL, TOP_K_FINAL, + BENCHMARK_SYSTEM_PROMPT, CHAT_SYSTEM_PROMPT, + FuzzyEvaluator, Evaluator, call_ollama, +) + +# โ”€โ”€ Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +ARANGO_HOST = os.environ.get('ARANGO_HOST', 'https://bfc25a0e3c74.arangodb.cloud:8529') +ARANGO_USER = os.environ.get('ARANGO_USER', 'root') +ARANGO_PASS = os.environ.get('ARANGO_PASS', '') +ARANGO_DB = os.environ.get('ARANGO_DB', 'pubmed_graph') + +TOP_K_CANDIDATES = 75 +CROSS_ENCODER = 'cross-encoder/ms-marco-MiniLM-L-6-v2' +VECTOR_CACHE_FILE = os.path.join(ROOT, 'pubmed_vectors_cache.pkl') +RESULTS_DIR = os.path.join(ROOT, 'results') +RESULTS_FILE = os.path.join(RESULTS_DIR, 'graphrag_results.json') +BENCHMARK_N = 100 # must match run_plainrag.py + +os.makedirs(RESULTS_DIR, exist_ok=True) + +if not ARANGO_PASS: + raise EnvironmentError( + 'Set ARANGO_PASS before running:\n' + ' PowerShell : $env:ARANGO_PASS = "your_password"\n' + ' CMD : set ARANGO_PASS=your_password' + ) + +# โ”€โ”€ ArangoDB โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def connect_arango(host, user, password, db_name, max_retries=5): + client = ArangoClient(hosts=host) + for attempt in range(max_retries): + try: + sys_db = client.db('_system', username=user, password=password) + sys_db.version() + db = client.db(db_name, username=user, password=password) + print('[ArangoDB] Connected.') + return db + except (ServerConnectionError, ArangoServerError) as exc: + wait = (attempt + 1) * 5 + print(f'[ArangoDB] Attempt {attempt + 1} failed. Retrying in {wait}s...') + time.sleep(wait) + raise ConnectionError('Could not connect to ArangoDB.') + + +def load_chunk_vectors(db, collection='Chunks', cache_file=VECTOR_CACHE_FILE): + if os.path.exists(cache_file): + print(f'[Cache] Loading from {cache_file}...') + try: + with open(cache_file, 'rb') as f: + data = pickle.load(f) + ids, texts, embeddings = data['ids'], data['texts'], data['embeddings'] + if len(embeddings) > 0: + print(f'[Cache] Loaded {len(embeddings):,} vectors.') + return ids, texts, np.array(embeddings) + except Exception as exc: + print(f'[Cache] Corrupted ({exc}). Re-downloading...') + + print('[Index] Downloading vectors from ArangoDB (first run only)...') + ids, texts, embeddings = [], [], [] + BATCH, offset = 5000, 0 + + try: + total = list(db.aql.execute(f'RETURN LENGTH({collection})'))[0] + except Exception: + total = 0 + + with tqdm(total=total, desc='Downloading') as pbar: + while True: + aql = f''' + FOR c IN {collection} + FILTER c.embedding != null + LIMIT {offset}, {BATCH} + RETURN {{ id: c._id, text: c.text, emb: c.embedding }} + ''' + try: + batch = list(db.aql.execute(aql, ttl=3600)) + except Exception as exc: + print(f'[Index] Error: {exc}') + if '503' in str(exc): + time.sleep(5) + break + + if not batch: + break + for doc in batch: + ids.append(doc['id']) + texts.append(doc['text']) + embeddings.append(doc['emb']) + + pbar.update(len(batch)) + offset += len(batch) + if len(batch) < BATCH: + break + time.sleep(0.1) + + embeddings_np = np.array(embeddings) + if ids: + with open(cache_file, 'wb') as f: + pickle.dump({'ids': ids, 'texts': texts, 'embeddings': embeddings_np}, f) + print(f'[Cache] Saved {len(ids):,} vectors.') + return ids, texts, embeddings_np + +# โ”€โ”€ GraphRAG โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class GraphRAG: + def __init__(self, db, chunk_ids, chunk_texts, chunk_embeddings): + self.db = db + self.chunk_ids = chunk_ids + self.chunk_texts = chunk_texts + self.chunk_embeddings = chunk_embeddings + print('[Model] Loading Sentence Transformer...') + self.encoder = SentenceTransformer(EMBEDDING_MODEL) + print('[Model] Loading CrossEncoder...') + self.reranker = CrossEncoder(CROSS_ENCODER) + print('[GraphRAG] Initialised.') + + def retrieve(self, query: str) -> str: + if len(self.chunk_embeddings) == 0: + return 'No context available.' + + query_emb = self.encoder.encode([query]) + sims = cosine_similarity(query_emb, self.chunk_embeddings)[0] + top_idx = np.argsort(sims)[-TOP_K_CANDIDATES:][::-1] + candidates = [(self.chunk_texts[i], self.chunk_ids[i]) for i in top_idx] + + pairs = [[query, text] for text, _ in candidates] + scores = self.reranker.predict(pairs) + best_idx = np.argsort(scores)[::-1][:TOP_K_FINAL] + best_ids = [candidates[i][1] for i in best_idx] + + return self._expand_via_graph(best_ids) + + def _expand_via_graph(self, chunk_ids: list) -> str: + aql = ''' + WITH Papers, Chunks + FOR cid IN @ids + LET chunk = DOCUMENT(cid) + FOR paper IN 1..1 INBOUND chunk HAS_CONTEXT + LET all_chunks = ( + FOR c IN 1..1 OUTBOUND paper HAS_CONTEXT + RETURN c.text + ) + LET full_abstract = CONCAT_SEPARATOR(" ", all_chunks) + RETURN { title: paper.title, abstract: full_abstract } + ''' + try: + rows = list(self.db.aql.execute(aql, bind_vars={'ids': chunk_ids})) + seen = set() + parts = [] + for row in rows: + title = row.get('title', 'Unknown Study') + if title in seen: + continue + seen.add(title) + abstract = row.get('abstract', '') + parts.append('=== STUDY: ' + title + ' ===\n' + abstract) + return '\n\n'.join(parts) if parts else 'No context found.' + except Exception as exc: + print(f'[GraphRAG] Graph expansion failed ({exc}). Using raw chunks.') + return '\n\n'.join( + 'Excerpt: ' + text + for text, _ in candidates[:TOP_K_FINAL] + ) + + def answer_benchmark(self, question: str) -> str: + context = self.retrieve(question) + prompt = 'Context:\n' + context + '\n\nQuestion: ' + question + return call_ollama(prompt, system=BENCHMARK_SYSTEM_PROMPT, temperature=0.0) + + +# โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +if __name__ == '__main__': + db = connect_arango(ARANGO_HOST, ARANGO_USER, ARANGO_PASS, ARANGO_DB) + chunk_ids, chunk_texts, chunk_embeddings = load_chunk_vectors(db) + rag = GraphRAG(db, chunk_ids, chunk_texts, chunk_embeddings) + + dataset = load_dataset('qiaojin/PubMedQA', 'pqa_labeled', split='train') + fuzzy = FuzzyEvaluator() + evaluator = Evaluator('GraphRAG') + + print(f'\n=== GraphRAG Benchmark (n={BENCHMARK_N}) ===') + + for i, item in enumerate(dataset): + if i >= BENCHMARK_N: + break + + question = item['question'] + gt = item['final_decision'] + + t0 = time.time() + raw = rag.answer_benchmark(question) + latency = time.time() - t0 + + pred = fuzzy.extract_answer(raw) + evaluator.record(gt, pred, latency) + + icon = 'v' if pred == gt else 'x' + print(f'[{i+1:3d}] GT={gt:<5} Pred={pred:<5} {icon} ({latency:.1f}s)') + + evaluator.report() + evaluator.save(RESULTS_FILE) diff --git a/run_plainrag.py b/run_plainrag.py new file mode 100644 index 0000000..8c537fb --- /dev/null +++ b/run_plainrag.py @@ -0,0 +1,162 @@ +""" +Plain RAG benchmark runner โ€” standalone script (no Jupyter required). +Runs the same pipeline as Plain_RAG/Plain_RAG.ipynb. +""" + +import os +import sys +import time +import subprocess + +# Add repo root to path so shared_utils is importable +ROOT = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, ROOT) + +# โ”€โ”€ Start Ollama (idempotent โ€” safe to call if already running) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +print('[Ollama] Starting server...') +subprocess.Popen( + ['ollama', 'serve'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, +) +time.sleep(3) + +# โ”€โ”€ Imports (after Ollama is up) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +import os +import re +import pickle +import requests +import numpy as np +import faiss +import torch +from datasets import load_dataset +from sentence_transformers import SentenceTransformer +from tqdm import tqdm + +from shared_utils import ( + EMBEDDING_MODEL, LLM_MODEL, TOP_K_FINAL, + BENCHMARK_SYSTEM_PROMPT, CHAT_SYSTEM_PROMPT, + FuzzyEvaluator, Evaluator, call_ollama, +) + +# โ”€โ”€ Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +PLAIN_RAG_DIR = os.path.join(ROOT, 'Plain_RAG') +INDEX_FILE = os.path.join(PLAIN_RAG_DIR, 'pubmed_rag_index.bin') +DATA_FILE = os.path.join(PLAIN_RAG_DIR, 'pubmed_rag_data.pkl') +RESULTS_DIR = os.path.join(ROOT, 'results') +RESULTS_FILE = os.path.join(RESULTS_DIR, 'plainrag_results.json') +BENCHMARK_N = 100 # change to run more samples + +os.makedirs(RESULTS_DIR, exist_ok=True) + +# โ”€โ”€ PlainRAG โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class PlainRAG: + def __init__(self): + self.device = 'cuda' if torch.cuda.is_available() else 'cpu' + print(f'[PlainRAG] Initialising on {self.device}') + self.embedder = SentenceTransformer(EMBEDDING_MODEL, device=self.device) + self.dim = self.embedder.get_sentence_embedding_dimension() + self.index = None + self.documents = [] + self.labeled_data = [] + + def load_data(self): + if os.path.exists(INDEX_FILE) and os.path.exists(DATA_FILE): + print('[Index] Loading from disk...') + self.index = faiss.read_index(INDEX_FILE) + with open(DATA_FILE, 'rb') as f: + saved = pickle.load(f) + self.documents = saved['documents'] + self.labeled_data = saved['labeled_data'] + print(f'[Index] Loaded {len(self.documents):,} documents.') + return + + print('[Data] Building index from scratch (first run โ€” takes ~10 min)...') + ds_labeled = load_dataset('qiaojin/PubMedQA', 'pqa_labeled', split='train') + ds_unlabeled = load_dataset('qiaojin/PubMedQA', 'pqa_unlabeled', split='train') + ds_artificial = load_dataset('qiaojin/PubMedQA', 'pqa_artificial', split='train') + + def process_split(ds, name): + docs = [] + for item in tqdm(ds, desc=f'Processing {name}'): + docs.append({ + 'text': ' '.join(item['context']['contexts']), + 'pubid': item['pubid'], + 'question': item.get('question', ''), + 'final_decision': item.get('final_decision'), + }) + return docs + + self.labeled_data = process_split(ds_labeled, 'labeled') + all_docs = list(self.labeled_data) + all_docs.extend(process_split(ds_unlabeled, 'unlabeled')) + all_docs.extend(process_split(ds_artificial, 'artificial')) + self.documents = all_docs + + print(f'[Embed] Encoding {len(self.documents):,} documents...') + embeddings = self.embedder.encode( + [d['text'] for d in self.documents], + batch_size=128, + show_progress_bar=True, + convert_to_numpy=True, + normalize_embeddings=True, + ) + + print('[Index] Building FAISS IndexFlatIP...') + index_cpu = faiss.IndexFlatIP(self.dim) + index_cpu.add(embeddings) + os.makedirs(PLAIN_RAG_DIR, exist_ok=True) + faiss.write_index(index_cpu, INDEX_FILE) + with open(DATA_FILE, 'wb') as f: + pickle.dump({'documents': self.documents, 'labeled_data': self.labeled_data}, f) + print(f'[Index] Saved to {INDEX_FILE}.') + self.index = index_cpu + + def retrieve(self, query: str) -> str: + vec = self.embedder.encode([query], convert_to_numpy=True, normalize_embeddings=True) + _, indices = self.index.search(vec, TOP_K_FINAL) + retrieved = [self.documents[i] for i in indices[0] if i != -1] + return '\n\n'.join( + 'Abstract ' + str(i + 1) + ': ' + doc['text'] + for i, doc in enumerate(retrieved) + ) + + def answer_benchmark(self, question: str) -> str: + context = self.retrieve(question) + prompt = 'Context:\n' + context + '\n\nQuestion: ' + question + return call_ollama(prompt, system=BENCHMARK_SYSTEM_PROMPT, temperature=0.0) + + +# โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +if __name__ == '__main__': + rag = PlainRAG() + rag.load_data() + + fuzzy = FuzzyEvaluator() + evaluator = Evaluator('Plain RAG') + + print(f'\n=== Plain RAG Benchmark (n={BENCHMARK_N}) ===') + + for i, item in enumerate(rag.labeled_data): + if i >= BENCHMARK_N: + break + + question = item.get('question', '') + gt = item.get('final_decision', '') + if not question or not gt: + continue + + t0 = time.time() + raw = rag.answer_benchmark(question) + latency = time.time() - t0 + + pred = fuzzy.extract_answer(raw) + evaluator.record(gt, pred, latency) + + icon = 'v' if pred == gt else 'x' + print(f'[{i+1:3d}] GT={gt:<5} Pred={pred:<5} {icon} ({latency:.1f}s)') + + evaluator.report() + evaluator.save(RESULTS_FILE) diff --git a/shared_utils.py b/shared_utils.py new file mode 100644 index 0000000..8007751 --- /dev/null +++ b/shared_utils.py @@ -0,0 +1,153 @@ +""" +Shared utilities for the Knowledge Graph QA project. + +GraphRAG.ipynb and Plain_RAG/Plain_RAG.ipynb both import from this module +to guarantee identical embedding models, LLM, prompts, answer extraction, +and evaluation โ€” so the only variables between them are the retrieval strategy +and context assembly method. +""" + +import re +import json +import time +import requests +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import seaborn as sns +from sklearn.metrics import accuracy_score, classification_report, confusion_matrix + +# โ”€โ”€ Model identifiers (both notebooks must use these exact values) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +EMBEDDING_MODEL = 'sentence-transformers/all-MiniLM-L6-v2' # 384-dim +LLM_MODEL = 'deepseek-r1:8b' +OLLAMA_API = 'http://localhost:11434/api/chat' +TOP_K_FINAL = 3 # documents fed to the LLM in both pipelines + +# โ”€โ”€ Prompts (word-for-word identical in both pipelines) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +BENCHMARK_SYSTEM_PROMPT = ( + 'You are a PubMedQA annotator. Classify the answer as yes, no, or maybe.\n\n' + 'Guidelines:\n' + '- YES : the study finds a positive outcome, correlation, or association,\n' + ' even if further research is recommended.\n' + '- NO : the study finds no significant difference or a negative result.\n' + '- MAYBE: only if the abstract explicitly states inconclusive results\n' + ' with no supporting data.\n\n' + 'End your response with exactly: Final Answer: [yes/no/maybe]' +) + +CHAT_SYSTEM_PROMPT = ( + 'You are a helpful medical AI assistant. ' + 'Use the provided research abstracts to answer the user question. ' + 'Cite specific study titles when making claims. ' + 'If studies conflict, explain the conflict. ' + 'If the context is insufficient, say so and give your best assessment.' +) + + +# โ”€โ”€ Answer extraction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class FuzzyEvaluator: + """Extracts and normalises yes/no/maybe from verbose model output.""" + + def extract_answer(self, text: str) -> str: + clean = re.sub(r'.*?', '', text, flags=re.DOTALL).lower() + match = re.search(r'final answer\s*:\s*(yes|no|maybe)', clean) + if match: + return match.group(1) + matches = re.findall(r'\b(yes|no|maybe)\b', clean) + return matches[-1] if matches else 'maybe' + + +# โ”€โ”€ Evaluation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class Evaluator: + """Records predictions and generates a full evaluation report.""" + + def __init__(self, model_name: str): + self.model_name = model_name + self.y_true: list = [] + self.y_pred: list = [] + self.latencies: list = [] + + def record(self, ground_truth: str, prediction: str, latency: float = 0.0): + pred = prediction.lower().strip() + if pred not in ('yes', 'no', 'maybe'): + pred = 'maybe' + self.y_true.append(ground_truth.lower().strip()) + self.y_pred.append(pred) + self.latencies.append(latency) + + def report(self) -> dict: + if not self.y_true: + print('No data recorded.') + return {} + + labels = ['yes', 'no', 'maybe'] + acc = accuracy_score(self.y_true, self.y_pred) + total_t = sum(self.latencies) + avg_t = total_t / len(self.latencies) if self.latencies else 0.0 + + print(f"\n{'=' * 52}") + print(f" {self.model_name} โ€” Evaluation Report") + print(f"{'=' * 52}") + print(f" Samples : {len(self.y_true)}") + print(f" Accuracy : {acc:.2%}") + print(f" Total time : {total_t:.1f}s | Avg/query : {avg_t:.1f}s") + print(f"{'-' * 52}") + print(classification_report(self.y_true, self.y_pred, labels=labels, zero_division=0)) + + cm = confusion_matrix(self.y_true, self.y_pred, labels=labels) + fig, ax = plt.subplots(figsize=(6, 5)) + sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', + xticklabels=labels, yticklabels=labels, ax=ax) + ax.set_xlabel('Predicted') + ax.set_ylabel('Actual') + ax.set_title(f'Confusion Matrix โ€” {self.model_name}') + plt.tight_layout() + plt.show() + + return { + 'model': self.model_name, + 'accuracy': acc, + 'samples': len(self.y_true), + 'total_time': total_t, + 'avg_latency': avg_t, + 'y_true': self.y_true, + 'y_pred': self.y_pred, + } + + def save(self, path: str): + data = { + 'model': self.model_name, + 'accuracy': accuracy_score(self.y_true, self.y_pred) if self.y_true else 0, + 'samples': len(self.y_true), + 'total_time': sum(self.latencies), + 'avg_latency': sum(self.latencies) / len(self.latencies) if self.latencies else 0, + 'y_true': self.y_true, + 'y_pred': self.y_pred, + } + with open(path, 'w') as f: + json.dump(data, f, indent=2) + print(f'Results saved to {path}') + + +# โ”€โ”€ LLM interface โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def call_ollama(prompt: str, system: str = '', + temperature: float = 0.0, + model: str = LLM_MODEL) -> str: + """Single synchronous call to the local Ollama API.""" + messages = [] + if system: + messages.append({'role': 'system', 'content': system}) + messages.append({'role': 'user', 'content': prompt}) + + payload = { + 'model': model, + 'messages': messages, + 'stream': False, + 'options': {'temperature': temperature, 'num_ctx': 4096}, + } + resp = requests.post(OLLAMA_API, json=payload, timeout=300) + resp.raise_for_status() + return resp.json()['message']['content'] From fdc5cdc9d3f82cf0dd6e78eb3a919830a9f7a9ca Mon Sep 17 00:00:00 2001 From: vardhjain Date: Thu, 11 Jun 2026 17:45:52 -0400 Subject: [PATCH 02/23] =?UTF-8?q?Fix=20ensure=5Follama=20PATH=20on=20Colab?= =?UTF-8?q?=20=E2=80=94=20use=20shutil.which=20with=20/usr/local/bin=20fal?= =?UTF-8?q?lback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GraphRAG.ipynb | 141 +++++++++++++++++++++++++++----------- Plain_RAG/Plain_RAG.ipynb | 123 +++++++++++++++++++++++---------- 2 files changed, 190 insertions(+), 74 deletions(-) diff --git a/GraphRAG.ipynb b/GraphRAG.ipynb index bb2ba1d..45b3d9f 100644 --- a/GraphRAG.ipynb +++ b/GraphRAG.ipynb @@ -2,12 +2,21 @@ "nbformat": 4, "nbformat_minor": 5, "metadata": { - "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}, - "language_info": {"name": "python", "version": "3.10.0"} + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } }, "cells": [ { - "cell_type": "markdown", "id": "md-title", "metadata": {}, + "cell_type": "markdown", + "id": "md-title", + "metadata": {}, "source": [ "# GraphRAG โ€” Knowledge-Graph-Augmented Retrieval\n", "\n", @@ -24,14 +33,22 @@ ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-install", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-install", + "metadata": {}, + "outputs": [], "source": [ "!pip install python-arango sentence-transformers datasets gradio -q\n", "!curl -fsSL https://ollama.com/install.sh | sh" ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-imports", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-imports", + "metadata": {}, + "outputs": [], "source": [ "import os, re, sys, time, json, pickle, subprocess, requests\n", "import numpy as np\n", @@ -49,7 +66,11 @@ ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-shared", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-shared", + "metadata": {}, + "outputs": [], "source": [ "# โ”€โ”€ Shared constants (word-for-word identical in Plain_RAG.ipynb) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", "EMBEDDING_MODEL = 'sentence-transformers/all-MiniLM-L6-v2'\n", @@ -146,7 +167,11 @@ ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-config", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-config", + "metadata": {}, + "outputs": [], "source": [ "# โ”€โ”€ ArangoDB credentials โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", "# In Colab: add ARANGO_PASS in Secrets (key icon in sidebar)\n", @@ -177,24 +202,19 @@ ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-ollama", "metadata": {}, "outputs": [], - "source": [ - "def ensure_ollama(model=LLM_MODEL):\n", - " subprocess.Popen(['ollama', 'serve'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n", - " time.sleep(3)\n", - " result = subprocess.run(['ollama', 'list'], capture_output=True, text=True)\n", - " if model in result.stdout:\n", - " print(f'[Ollama] {model} is ready.')\n", - " return\n", - " print(f'[Ollama] Pulling {model}...')\n", - " subprocess.run(['ollama', 'pull', model], check=True)\n", - " print(f'[Ollama] {model} ready.')\n", - "\n", - "ensure_ollama()" - ] + "cell_type": "code", + "execution_count": null, + "id": "cell-ollama", + "metadata": {}, + "outputs": [], + "source": "def ensure_ollama(model=LLM_MODEL):\n import shutil\n ollama = shutil.which('ollama') or '/usr/local/bin/ollama'\n subprocess.Popen([ollama, 'serve'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n time.sleep(3)\n result = subprocess.run([ollama, 'list'], capture_output=True, text=True)\n if model in result.stdout:\n print(f'[Ollama] {model} is ready.')\n return\n print(f'[Ollama] Pulling {model}...')\n subprocess.run([ollama, 'pull', model], check=True)\n print(f'[Ollama] {model} ready.')\n\nensure_ollama()" }, { - "cell_type": "code", "execution_count": null, "id": "cell-arango", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-arango", + "metadata": {}, + "outputs": [], "source": [ "def connect_arango(host, user, password, db_name, max_retries=5):\n", " client = ArangoClient(hosts=host)\n", @@ -215,7 +235,11 @@ ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-cache", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-cache", + "metadata": {}, + "outputs": [], "source": [ "def load_chunk_vectors(db, collection='Chunks', cache_file=VECTOR_CACHE_FILE):\n", " if os.path.exists(cache_file):\n", @@ -264,7 +288,11 @@ ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-graphrag-class", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-graphrag-class", + "metadata": {}, + "outputs": [], "source": [ "class GraphRAG:\n", " \"\"\"\n", @@ -330,19 +358,31 @@ ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-init", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-init", + "metadata": {}, + "outputs": [], "source": [ "chunk_ids, chunk_texts, chunk_embeddings = load_chunk_vectors(db)\n", "rag = GraphRAG(db, chunk_ids, chunk_texts, chunk_embeddings)" ] }, { - "cell_type": "markdown", "id": "md-benchmark", "metadata": {}, - "source": ["## Benchmark Evaluation\n", - "Evaluates on `pqa_labeled` (1 000 ground-truth samples). Adjust `BENCHMARK_N` in the config cell."] + "cell_type": "markdown", + "id": "md-benchmark", + "metadata": {}, + "source": [ + "## Benchmark Evaluation\n", + "Evaluates on `pqa_labeled` (1 000 ground-truth samples). Adjust `BENCHMARK_N` in the config cell." + ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-benchmark", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-benchmark", + "metadata": {}, + "outputs": [], "source": [ "dataset = load_dataset('qiaojin/PubMedQA', 'pqa_labeled', split='train')\n", "fuzzy = FuzzyEvaluator()\n", @@ -363,15 +403,30 @@ ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-save", "metadata": {}, "outputs": [], - "source": ["evaluator.save(RESULTS_FILE)"] + "cell_type": "code", + "execution_count": null, + "id": "cell-save", + "metadata": {}, + "outputs": [], + "source": [ + "evaluator.save(RESULTS_FILE)" + ] }, { - "cell_type": "markdown", "id": "md-compare", "metadata": {}, - "source": ["## Head-to-Head Comparison\n", "Run after both notebooks have saved results. Also available as a standalone `Comparison.ipynb`."] + "cell_type": "markdown", + "id": "md-compare", + "metadata": {}, + "source": [ + "## Head-to-Head Comparison\n", + "Run after both notebooks have saved results. Also available as a standalone `Comparison.ipynb`." + ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-compare", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-compare", + "metadata": {}, + "outputs": [], "source": [ "from sklearn.metrics import f1_score\n", "import pandas as pd\n", @@ -409,11 +464,19 @@ ] }, { - "cell_type": "markdown", "id": "md-ui", "metadata": {}, - "source": ["## Interactive Chat UI"] + "cell_type": "markdown", + "id": "md-ui", + "metadata": {}, + "source": [ + "## Interactive Chat UI" + ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-ui", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-ui", + "metadata": {}, + "outputs": [], "source": [ "def launch_ui(rag_instance):\n", " def chat_fn(message, history): return rag_instance.answer_chat(message)\n", @@ -430,4 +493,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/Plain_RAG/Plain_RAG.ipynb b/Plain_RAG/Plain_RAG.ipynb index a2f0e74..7a1b42a 100644 --- a/Plain_RAG/Plain_RAG.ipynb +++ b/Plain_RAG/Plain_RAG.ipynb @@ -2,12 +2,21 @@ "nbformat": 4, "nbformat_minor": 5, "metadata": { - "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}, - "language_info": {"name": "python", "version": "3.10.0"} + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } }, "cells": [ { - "cell_type": "markdown", "id": "md-title", "metadata": {}, + "cell_type": "markdown", + "id": "md-title", + "metadata": {}, "source": [ "# Plain RAG โ€” Flat FAISS Vector Baseline\n", "\n", @@ -23,7 +32,11 @@ ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-install", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-install", + "metadata": {}, + "outputs": [], "source": [ "# On Colab with GPU: replace faiss-cpu with faiss-gpu-cu12\n", "!pip install faiss-cpu sentence-transformers datasets gradio -q\n", @@ -32,7 +45,11 @@ ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-imports", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-imports", + "metadata": {}, + "outputs": [], "source": [ "import os, re, sys, time, json, pickle, subprocess, requests\n", "import numpy as np\n", @@ -55,7 +72,11 @@ ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-shared", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-shared", + "metadata": {}, + "outputs": [], "source": [ "# โ”€โ”€ Shared constants (word-for-word identical in GraphRAG.ipynb) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", "EMBEDDING_MODEL = 'sentence-transformers/all-MiniLM-L6-v2'\n", @@ -152,7 +173,11 @@ ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-config", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-config", + "metadata": {}, + "outputs": [], "source": [ "# โ”€โ”€ Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", "INDEX_FILE = 'pubmed_rag_index.bin'\n", @@ -166,24 +191,19 @@ ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-ollama", "metadata": {}, "outputs": [], - "source": [ - "def ensure_ollama(model=LLM_MODEL):\n", - " subprocess.Popen(['ollama', 'serve'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n", - " time.sleep(3)\n", - " result = subprocess.run(['ollama', 'list'], capture_output=True, text=True)\n", - " if model in result.stdout:\n", - " print(f'[Ollama] {model} is ready.')\n", - " return\n", - " print(f'[Ollama] Pulling {model} (~5 GB, may take a few minutes)...')\n", - " subprocess.run(['ollama', 'pull', model], check=True)\n", - " print(f'[Ollama] {model} ready.')\n", - "\n", - "ensure_ollama()" - ] + "cell_type": "code", + "execution_count": null, + "id": "cell-ollama", + "metadata": {}, + "outputs": [], + "source": "def ensure_ollama(model=LLM_MODEL):\n import shutil\n ollama = shutil.which('ollama') or '/usr/local/bin/ollama'\n subprocess.Popen([ollama, 'serve'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n time.sleep(3)\n result = subprocess.run([ollama, 'list'], capture_output=True, text=True)\n if model in result.stdout:\n print(f'[Ollama] {model} is ready.')\n return\n print(f'[Ollama] Pulling {model} (~5 GB, may take a few minutes)...')\n subprocess.run([ollama, 'pull', model], check=True)\n print(f'[Ollama] {model} ready.')\n\nensure_ollama()" }, { - "cell_type": "code", "execution_count": null, "id": "cell-plainrag-class", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-plainrag-class", + "metadata": {}, + "outputs": [], "source": [ "class PlainRAG:\n", " \"\"\"\n", @@ -278,14 +298,20 @@ ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-init", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-init", + "metadata": {}, + "outputs": [], "source": [ "rag = PlainRAG()\n", "rag.load_data()" ] }, { - "cell_type": "markdown", "id": "md-benchmark", "metadata": {}, + "cell_type": "markdown", + "id": "md-benchmark", + "metadata": {}, "source": [ "## Benchmark Evaluation\n", "Evaluates on `pqa_labeled` (1 000 ground-truth samples). Adjust `BENCHMARK_N` in the config cell.\n", @@ -294,7 +320,11 @@ ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-benchmark", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-benchmark", + "metadata": {}, + "outputs": [], "source": [ "dataset = load_dataset('qiaojin/PubMedQA', 'pqa_labeled', split='train')\n", "fuzzy = FuzzyEvaluator()\n", @@ -315,15 +345,30 @@ ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-save", "metadata": {}, "outputs": [], - "source": ["evaluator.save(RESULTS_FILE)"] + "cell_type": "code", + "execution_count": null, + "id": "cell-save", + "metadata": {}, + "outputs": [], + "source": [ + "evaluator.save(RESULTS_FILE)" + ] }, { - "cell_type": "markdown", "id": "md-compare", "metadata": {}, - "source": ["## Head-to-Head Comparison\n", "Run after both notebooks have saved results. Also available as a standalone `Comparison.ipynb`."] + "cell_type": "markdown", + "id": "md-compare", + "metadata": {}, + "source": [ + "## Head-to-Head Comparison\n", + "Run after both notebooks have saved results. Also available as a standalone `Comparison.ipynb`." + ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-compare", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-compare", + "metadata": {}, + "outputs": [], "source": [ "from sklearn.metrics import f1_score\n", "\n", @@ -360,11 +405,19 @@ ] }, { - "cell_type": "markdown", "id": "md-ui", "metadata": {}, - "source": ["## Interactive Chat UI"] + "cell_type": "markdown", + "id": "md-ui", + "metadata": {}, + "source": [ + "## Interactive Chat UI" + ] }, { - "cell_type": "code", "execution_count": null, "id": "cell-ui", "metadata": {}, "outputs": [], + "cell_type": "code", + "execution_count": null, + "id": "cell-ui", + "metadata": {}, + "outputs": [], "source": [ "def launch_ui(rag_instance):\n", " def chat_fn(message, history): return rag_instance.answer_chat(message)\n", @@ -381,4 +434,4 @@ ] } ] -} +} \ No newline at end of file From 4f63aa6bca333c41ab06eae94c105dab657adfe6 Mon Sep 17 00:00:00 2001 From: vardhjain Date: Thu, 11 Jun 2026 18:04:05 -0400 Subject: [PATCH 03/23] Fix Ollama install on Colab: add zstd dep + robust binary search The Ollama install script requires zstd for extraction (new in recent versions). Added `apt-get install -y zstd -q` before the curl install. Also expanded ensure_ollama() to search multiple candidate paths and fall back to a filesystem find rather than hard-coding /usr/local/bin. Both Plain_RAG and GraphRAG notebooks updated identically. --- GraphRAG.ipynb | 7 ++----- Plain_RAG/Plain_RAG.ipynb | 9 ++------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/GraphRAG.ipynb b/GraphRAG.ipynb index 45b3d9f..cf7b778 100644 --- a/GraphRAG.ipynb +++ b/GraphRAG.ipynb @@ -38,10 +38,7 @@ "id": "cell-install", "metadata": {}, "outputs": [], - "source": [ - "!pip install python-arango sentence-transformers datasets gradio -q\n", - "!curl -fsSL https://ollama.com/install.sh | sh" - ] + "source": "!pip install python-arango sentence-transformers datasets gradio -q\n!apt-get install -y zstd -q\n!curl -fsSL https://ollama.com/install.sh | sh" }, { "cell_type": "code", @@ -207,7 +204,7 @@ "id": "cell-ollama", "metadata": {}, "outputs": [], - "source": "def ensure_ollama(model=LLM_MODEL):\n import shutil\n ollama = shutil.which('ollama') or '/usr/local/bin/ollama'\n subprocess.Popen([ollama, 'serve'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n time.sleep(3)\n result = subprocess.run([ollama, 'list'], capture_output=True, text=True)\n if model in result.stdout:\n print(f'[Ollama] {model} is ready.')\n return\n print(f'[Ollama] Pulling {model}...')\n subprocess.run([ollama, 'pull', model], check=True)\n print(f'[Ollama] {model} ready.')\n\nensure_ollama()" + "source": "def ensure_ollama(model=LLM_MODEL):\n import shutil, os\n ollama = shutil.which('ollama')\n if not ollama:\n for p in ['/usr/local/bin/ollama', '/usr/bin/ollama',\n os.path.expanduser('~/.ollama/bin/ollama'),\n '/opt/ollama/bin/ollama']:\n if os.path.isfile(p) and os.access(p, os.X_OK):\n ollama = p\n break\n if not ollama:\n try:\n res = subprocess.run(\n ['find', '/usr', os.path.expanduser('~'), '-name', 'ollama', '-type', 'f'],\n capture_output=True, text=True, timeout=15)\n for line in res.stdout.splitlines():\n if line.strip() and os.access(line.strip(), os.X_OK):\n ollama = line.strip()\n break\n except Exception:\n pass\n if not ollama:\n raise FileNotFoundError('ollama binary not found โ€” re-run the install cell.')\n print(f'[Ollama] binary: {ollama}')\n subprocess.Popen([ollama, 'serve'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n time.sleep(3)\n result = subprocess.run([ollama, 'list'], capture_output=True, text=True)\n if model in result.stdout:\n print(f'[Ollama] {model} is ready.')\n return\n print(f'[Ollama] Pulling {model}...')\n subprocess.run([ollama, 'pull', model], check=True)\n print(f'[Ollama] {model} ready.')\nensure_ollama()" }, { "cell_type": "code", diff --git a/Plain_RAG/Plain_RAG.ipynb b/Plain_RAG/Plain_RAG.ipynb index 7a1b42a..8d894da 100644 --- a/Plain_RAG/Plain_RAG.ipynb +++ b/Plain_RAG/Plain_RAG.ipynb @@ -37,12 +37,7 @@ "id": "cell-install", "metadata": {}, "outputs": [], - "source": [ - "# On Colab with GPU: replace faiss-cpu with faiss-gpu-cu12\n", - "!pip install faiss-cpu sentence-transformers datasets gradio -q\n", - "# !pip install faiss-gpu-cu12 sentence-transformers datasets gradio -q\n", - "!curl -fsSL https://ollama.com/install.sh | sh" - ] + "source": "# On Colab with GPU: replace faiss-cpu with faiss-gpu-cu12\n# !pip install faiss-cpu sentence-transformers datasets gradio -q\n!pip install faiss-gpu-cu12 sentence-transformers datasets gradio -q\n!apt-get install -y zstd -q\n!curl -fsSL https://ollama.com/install.sh | sh" }, { "cell_type": "code", @@ -196,7 +191,7 @@ "id": "cell-ollama", "metadata": {}, "outputs": [], - "source": "def ensure_ollama(model=LLM_MODEL):\n import shutil\n ollama = shutil.which('ollama') or '/usr/local/bin/ollama'\n subprocess.Popen([ollama, 'serve'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n time.sleep(3)\n result = subprocess.run([ollama, 'list'], capture_output=True, text=True)\n if model in result.stdout:\n print(f'[Ollama] {model} is ready.')\n return\n print(f'[Ollama] Pulling {model} (~5 GB, may take a few minutes)...')\n subprocess.run([ollama, 'pull', model], check=True)\n print(f'[Ollama] {model} ready.')\n\nensure_ollama()" + "source": "def ensure_ollama(model=LLM_MODEL):\n import shutil, os\n ollama = shutil.which('ollama')\n if not ollama:\n for p in ['/usr/local/bin/ollama', '/usr/bin/ollama',\n os.path.expanduser('~/.ollama/bin/ollama'),\n '/opt/ollama/bin/ollama']:\n if os.path.isfile(p) and os.access(p, os.X_OK):\n ollama = p\n break\n if not ollama:\n try:\n res = subprocess.run(\n ['find', '/usr', os.path.expanduser('~'), '-name', 'ollama', '-type', 'f'],\n capture_output=True, text=True, timeout=15)\n for line in res.stdout.splitlines():\n if line.strip() and os.access(line.strip(), os.X_OK):\n ollama = line.strip()\n break\n except Exception:\n pass\n if not ollama:\n raise FileNotFoundError('ollama binary not found โ€” re-run the install cell.')\n print(f'[Ollama] binary: {ollama}')\n subprocess.Popen([ollama, 'serve'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n time.sleep(3)\n result = subprocess.run([ollama, 'list'], capture_output=True, text=True)\n if model in result.stdout:\n print(f'[Ollama] {model} is ready.')\n return\n print(f'[Ollama] Pulling {model} (~5 GB, may take a few minutes)...')\n subprocess.run([ollama, 'pull', model], check=True)\n print(f'[Ollama] {model} ready.')\nensure_ollama()" }, { "cell_type": "code", From e16372654afa5641ee7ad522fee665362f3e8836 Mon Sep 17 00:00:00 2001 From: vardhjain Date: Fri, 12 Jun 2026 13:23:05 -0400 Subject: [PATCH 04/23] Restructure repo and fix the GraphRAG vs PlainRAG comparison Implements the agreed revamp: an importable src/kgqa package, a 4-arm ablation (plain -> plain_rr -> graph -> graph_concepts), and the fairness fixes from the audit. Science fixes: - Single shared ChunkStore (identical corpus + per-section chunking across arms) - Reranker promoted to its own arm so the graph never gets it as a hidden edge - Label leakage removed: ingestion stores no question-derived title and no final_decision; graph context uses generic "=== STUDY n ===" labels - MeSH Concepts/MENTIONS now used via a concept-hop arm (graph_concepts) - Seeded random sampling (n=200) + paired McNemar significance test - Fixed the NameError in the graph-expansion fallback Repo hygiene: - src/ package, scripts/ (ingest, run_benchmark, compare), thin Colab notebooks - tests/ (17 pytest cases, CPU-only via fakes), ruff config, GitHub Actions CI - README, requirements, .gitignore, LICENSE (MIT), .env.example - Docs (PDF/PPTX) moved to docs/; .DS_Store untracked; superseded files removed --- .DS_Store | Bin 8196 -> 0 bytes .env.example | 12 + .github/workflows/ci.yml | 34 ++ .gitignore | 34 ++ Comparison.ipynb | 335 ------------ Data_Ingestion_KG.ipynb | 311 ----------- GraphRAG.ipynb | 493 ------------------ LICENSE | 21 + Plain_RAG/Plain_RAG.ipynb | 432 --------------- README.md | 148 ++++++ Graph_RAG_PPT.pptx => docs/Graph_RAG_PPT.pptx | Bin Project Report.pdf => docs/Project_Report.pdf | Bin notebooks/01_ingest.ipynb | 65 +++ notebooks/02_benchmark.ipynb | 111 ++++ pyproject.toml | 31 ++ requirements-dev.txt | 5 + requirements.txt | 26 + run_comparison.py | 127 ----- run_graphrag.py | 242 --------- run_plainrag.py | 162 ------ scripts/compare.py | 143 +++++ scripts/ingest.py | 139 +++++ scripts/run_benchmark.py | 106 ++++ shared_utils.py | 153 ------ src/kgqa/__init__.py | 14 + src/kgqa/config.py | 69 +++ src/kgqa/data.py | 77 +++ src/kgqa/evaluation.py | 131 +++++ src/kgqa/llm.py | 31 ++ src/kgqa/models.py | 44 ++ src/kgqa/prompts.py | 28 + src/kgqa/retrieval/__init__.py | 13 + src/kgqa/retrieval/base.py | 168 ++++++ src/kgqa/retrieval/graph.py | 118 +++++ src/kgqa/retrieval/plain.py | 27 + tests/__init__.py | 0 tests/conftest.py | 84 +++ tests/test_config.py | 25 + tests/test_evaluation.py | 49 ++ tests/test_retrieval.py | 88 ++++ 40 files changed, 1841 insertions(+), 2255 deletions(-) delete mode 100644 .DS_Store create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore delete mode 100644 Comparison.ipynb delete mode 100644 Data_Ingestion_KG.ipynb delete mode 100644 GraphRAG.ipynb create mode 100644 LICENSE delete mode 100644 Plain_RAG/Plain_RAG.ipynb create mode 100644 README.md rename Graph_RAG_PPT.pptx => docs/Graph_RAG_PPT.pptx (100%) rename Project Report.pdf => docs/Project_Report.pdf (100%) create mode 100644 notebooks/01_ingest.ipynb create mode 100644 notebooks/02_benchmark.ipynb create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt delete mode 100644 run_comparison.py delete mode 100644 run_graphrag.py delete mode 100644 run_plainrag.py create mode 100644 scripts/compare.py create mode 100644 scripts/ingest.py create mode 100644 scripts/run_benchmark.py delete mode 100644 shared_utils.py create mode 100644 src/kgqa/__init__.py create mode 100644 src/kgqa/config.py create mode 100644 src/kgqa/data.py create mode 100644 src/kgqa/evaluation.py create mode 100644 src/kgqa/llm.py create mode 100644 src/kgqa/models.py create mode 100644 src/kgqa/prompts.py create mode 100644 src/kgqa/retrieval/__init__.py create mode 100644 src/kgqa/retrieval/base.py create mode 100644 src/kgqa/retrieval/graph.py create mode 100644 src/kgqa/retrieval/plain.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_config.py create mode 100644 tests/test_evaluation.py create mode 100644 tests/test_retrieval.py diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index b92e42530d28bc4797597cd14416c08665765cd2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMU2GIp6u#fI&>1?=0g5cR6BY_V$O5GlTmH=UPXU1f+tO_*EW0}+9hjY2c6M8! zG&Ux_fM9$Q|Mf{Dkp}}xe9=S&^ik0SV|*ZL{CU6!Ur=H^Gj|sHLwzs?|K=t$=iYnn zxo6Iq@0+{#E@KSMMQtTxA;y?Umn)S@y4|6;diNSv1i!~sg6x@-OPh9@S{Kr}aifEd zAOk@Lf(!&12r>|4;Gd8ITC?3EPO$ECZEz1V5M3urr=4}B; zmQ%M^sIIsbk`Z6Vd^yY|X{dmklH{iFw;15&)F1K3#e6x;B{ygAHy`jl!{4F6^G@?e zyx|NnE`xiJfgl6pGvLyuj5#dNGU?V_bQo^6~fL`jsA zN@#GfzOEq}saxGJ6pak7sjH1f>Q~)AG$e_$YF4b6sI($Ql6@ICAxd$-lQ_4p8{&n6%5;QcIkPC zWKyYeDDX`g+uEMjU42)^Hj7!yNh;GbDKk?vQw6=rG~>omo%*VrOx`xl?Y3jM({v{a z`r(4B8>dS2ICj3!;!T!Rl%k{W)M+lGUvH%uorFOyUBl`zK|?w_ZT7r{_bsbySRZTK zv~|aoDrNeNnboq~Lm9RT#v_KF?;J3+LSJ{v$>^45So^#B4aZ2EdWV(D>eCUGq$^dS zIdks~tLj8U6UDS~sAv?9C^e$!`H0L{vY$A zgC$E5WJs8TN>pPm79xt( zXh0*@pc(Dhft`q>7kw~r01gi0CTqIg5g4dK+hF3{MKBbH|qu~ZgRKOc+<@FNLP$}n4RgOuL!n{VfCEO+wl@#%> z!uVBojeW;{VmArn(=ZbuEJiIM`vGjkHo~@x>`v@O5-FsiAqxvR93qq-!w??DFdoAR zLis5?iKp-^p2PEa5wGGkLi!oJK{$UK7w`_=#|OBGOFrViB)tECA30cWVR-9k4CCk9 z7|zjg@w{c))_$6)#G}!Va93Jo+)K#a|F=zi z{(o1VC2$gCAjrVqlL3^s##@`o_e9?!cds3xYad;1amNjF>6y@N)Ik8XZ~b9N^#~1h eZDPJ0=8}4--1UP1+xqiAw1f3OSpWYc>;Lb4$Dx)0 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e309334 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Copy to .env and fill in. Never commit .env (it is in .gitignore). +# On Google Colab, set these via the Secrets panel (key icon) instead. + +# โ”€โ”€ ArangoDB Oasis (GraphRAG only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +ARANGO_HOST=https://YOUR-DEPLOYMENT.arangodb.cloud:8529 +ARANGO_USER=root +ARANGO_PASS= +ARANGO_DB=pubmed_graph + +# โ”€โ”€ Ollama (LLM) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +OLLAMA_API=http://localhost:11434/api/chat +LLM_MODEL=deepseek-r1:8b diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..93443a7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main, revamp] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install test dependencies + # The heavy ML libraries (torch, sentence-transformers, faiss, arango, + # datasets) are imported lazily, so unit tests need only this light set. + run: | + python -m pip install --upgrade pip + python -m pip install numpy scikit-learn scipy requests pytest ruff + + - name: Lint (ruff) + run: ruff check src scripts tests + + - name: Test (pytest) + run: pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd4d01a --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# โ”€โ”€ OS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +.DS_Store +Thumbs.db + +# โ”€โ”€ Python โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +.venv/ +venv/ +env/ +.ipynb_checkpoints/ + +# โ”€โ”€ Secrets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +.env + +# โ”€โ”€ Caches & artifacts (regenerated; never committed) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +pubmed_vectors_cache.pkl +Plain_RAG/pubmed_rag_index.bin +Plain_RAG/pubmed_rag_data.pkl +*.bin +*.pkl + +# โ”€โ”€ Results (figures are committed; keep raw JSON if you want โ€” see README) โ”€โ”€โ”€โ”€ +# results/ is committed intentionally so the README can reference real numbers. + +# โ”€โ”€ Tooling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ diff --git a/Comparison.ipynb b/Comparison.ipynb deleted file mode 100644 index cd81c75..0000000 --- a/Comparison.ipynb +++ /dev/null @@ -1,335 +0,0 @@ -{ - "nbformat": 4, - "nbformat_minor": 5, - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.10.0" - } - }, - "cells": [ - { - "cell_type": "markdown", - "id": "md-title", - "metadata": {}, - "source": [ - "# GraphRAG vs Plain RAG โ€” Head-to-Head Comparison\n", - "\n", - "Run this notebook after both `GraphRAG.ipynb` and `Plain_RAG/Plain_RAG.ipynb`\n", - "have completed their benchmarks and saved results to the `results/` directory.\n", - "\n", - "**What is held constant (controlled variables):**\n", - "- Embedding model: `all-MiniLM-L6-v2`\n", - "- LLM: `deepseek-r1:8b` via Ollama\n", - "- Final retrieved documents fed to LLM: top-3\n", - "- System prompt (word-for-word identical)\n", - "- Answer extraction (`FuzzyEvaluator`)\n", - "- Evaluation dataset: `pqa_labeled` (same N samples, same order)\n", - "\n", - "**What differs (the independent variable):**\n", - "- **GraphRAG**: ArangoDB + wide search (top-75) + CrossEncoder reranking + graph expansion\n", - "- **Plain RAG**: FAISS + direct top-3 retrieval + concatenated chunk text" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-imports", - "metadata": {}, - "outputs": [], - "source": [ - "import json\n", - "import os\n", - "import numpy as np\n", - "import pandas as pd\n", - "import matplotlib.pyplot as plt\n", - "import matplotlib.gridspec as gridspec\n", - "import seaborn as sns\n", - "from sklearn.metrics import (\n", - " accuracy_score, classification_report, confusion_matrix, f1_score\n", - ")\n", - "\n", - "RESULTS_DIR = 'results'\n", - "GRAPHRAG_FILE = os.path.join(RESULTS_DIR, 'graphrag_results.json')\n", - "PLAINRAG_FILE = os.path.join(RESULTS_DIR, 'plainrag_results.json')\n", - "LABELS = ['yes', 'no', 'maybe']\n", - "\n", - "for path in [GRAPHRAG_FILE, PLAINRAG_FILE]:\n", - " if not os.path.exists(path):\n", - " raise FileNotFoundError(\n", - " f'{path} not found. Run the corresponding notebook first.'\n", - " )\n", - "\n", - "with open(GRAPHRAG_FILE) as f:\n", - " gr = json.load(f)\n", - "with open(PLAINRAG_FILE) as f:\n", - " pr = json.load(f)\n", - "\n", - "print(f\"GraphRAG : {gr['samples']} samples, accuracy={gr['accuracy']:.2%}, avg_latency={gr['avg_latency']:.1f}s\")\n", - "print(f\"Plain RAG : {pr['samples']} samples, accuracy={pr['accuracy']:.2%}, avg_latency={pr['avg_latency']:.1f}s\")" - ] - }, - { - "cell_type": "markdown", - "id": "md-summary", - "metadata": {}, - "source": [ - "## Summary metrics" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-summary", - "metadata": {}, - "outputs": [], - "source": [ - "models = [gr['model'], pr['model']]\n", - "accs = [gr['accuracy'] * 100, pr['accuracy'] * 100]\n", - "lats = [gr['avg_latency'], pr['avg_latency']]\n", - "\n", - "gr_f1 = f1_score(gr['y_true'], gr['y_pred'], labels=LABELS, average='macro', zero_division=0)\n", - "pr_f1 = f1_score(pr['y_true'], pr['y_pred'], labels=LABELS, average='macro', zero_division=0)\n", - "f1s = [gr_f1 * 100, pr_f1 * 100]\n", - "\n", - "summary = pd.DataFrame({\n", - " 'Model': models,\n", - " 'Accuracy (%)': [round(a, 2) for a in accs],\n", - " 'Macro F1 (%)': [round(f, 2) for f in f1s],\n", - " 'Avg Latency (s)': [round(l, 2) for l in lats],\n", - " 'Samples': [gr['samples'], pr['samples']],\n", - "})\n", - "summary = summary.set_index('Model')\n", - "print(summary.to_string())" - ] - }, - { - "cell_type": "markdown", - "id": "md-accuracy-latency", - "metadata": {}, - "source": [ - "## Accuracy & Latency comparison" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-acc-lat", - "metadata": {}, - "outputs": [], - "source": [ - "colours = ['#2196F3', '#FF9800']\n", - "fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n", - "\n", - "# Accuracy\n", - "bars = axes[0].bar(models, accs, color=colours, width=0.4, edgecolor='white')\n", - "axes[0].set_ylim(0, 100)\n", - "axes[0].set_ylabel('Accuracy (%)')\n", - "axes[0].set_title('Accuracy')\n", - "for bar, val in zip(bars, accs):\n", - " axes[0].text(\n", - " bar.get_x() + bar.get_width() / 2, val + 1.5,\n", - " f'{val:.1f}%', ha='center', fontweight='bold'\n", - " )\n", - "\n", - "# Macro F1\n", - "bars2 = axes[1].bar(models, f1s, color=colours, width=0.4, edgecolor='white')\n", - "axes[1].set_ylim(0, 100)\n", - "axes[1].set_ylabel('Macro F1 (%)')\n", - "axes[1].set_title('Macro F1 Score')\n", - "for bar, val in zip(bars2, f1s):\n", - " axes[1].text(\n", - " bar.get_x() + bar.get_width() / 2, val + 1.5,\n", - " f'{val:.1f}%', ha='center', fontweight='bold'\n", - " )\n", - "\n", - "# Latency\n", - "bars3 = axes[2].bar(models, lats, color=colours, width=0.4, edgecolor='white')\n", - "axes[2].set_ylabel('Avg latency (s / query)')\n", - "axes[2].set_title('Latency')\n", - "for bar, val in zip(bars3, lats):\n", - " axes[2].text(\n", - " bar.get_x() + bar.get_width() / 2, val * 1.03,\n", - " f'{val:.1f}s', ha='center', fontweight='bold'\n", - " )\n", - "\n", - "plt.suptitle(\n", - " f'GraphRAG vs Plain RAG (n={gr[\"samples\"]} samples each)',\n", - " fontsize=14, fontweight='bold', y=1.02\n", - ")\n", - "plt.tight_layout()\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "md-confusion", - "metadata": {}, - "source": [ - "## Confusion matrices" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-confusion", - "metadata": {}, - "outputs": [], - "source": [ - "fig, axes = plt.subplots(1, 2, figsize=(13, 5))\n", - "\n", - "for ax, res, colour in zip(axes, [gr, pr], ['Blues', 'Oranges']):\n", - " cm = confusion_matrix(res['y_true'], res['y_pred'], labels=LABELS)\n", - " sns.heatmap(cm, annot=True, fmt='d', cmap=colour,\n", - " xticklabels=LABELS, yticklabels=LABELS, ax=ax)\n", - " ax.set_xlabel('Predicted')\n", - " ax.set_ylabel('Actual')\n", - " ax.set_title(res['model'] + f\" (acc={res['accuracy']:.2%})\")\n", - "\n", - "plt.suptitle('Confusion Matrices', fontsize=13, fontweight='bold')\n", - "plt.tight_layout()\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "md-per-class", - "metadata": {}, - "source": [ - "## Per-class F1 comparison" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-per-class", - "metadata": {}, - "outputs": [], - "source": [ - "def per_class_f1(res):\n", - " f1s = f1_score(res['y_true'], res['y_pred'],\n", - " labels=LABELS, average=None, zero_division=0)\n", - " return dict(zip(LABELS, f1s))\n", - "\n", - "gr_f1s = per_class_f1(gr)\n", - "pr_f1s = per_class_f1(pr)\n", - "\n", - "x = np.arange(len(LABELS))\n", - "width = 0.35\n", - "\n", - "fig, ax = plt.subplots(figsize=(9, 5))\n", - "bars1 = ax.bar(x - width / 2, [gr_f1s[l] * 100 for l in LABELS],\n", - " width, label=gr['model'], color='#2196F3', edgecolor='white')\n", - "bars2 = ax.bar(x + width / 2, [pr_f1s[l] * 100 for l in LABELS],\n", - " width, label=pr['model'], color='#FF9800', edgecolor='white')\n", - "\n", - "ax.set_xticks(x)\n", - "ax.set_xticklabels(LABELS)\n", - "ax.set_ylabel('F1 Score (%)')\n", - "ax.set_ylim(0, 100)\n", - "ax.set_title('Per-class F1 Score')\n", - "ax.legend()\n", - "\n", - "for bar in bars1:\n", - " h = bar.get_height()\n", - " ax.text(bar.get_x() + bar.get_width() / 2, h + 1, f'{h:.1f}', ha='center', fontsize=9)\n", - "for bar in bars2:\n", - " h = bar.get_height()\n", - " ax.text(bar.get_x() + bar.get_width() / 2, h + 1, f'{h:.1f}', ha='center', fontsize=9)\n", - "\n", - "plt.tight_layout()\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "md-dist", - "metadata": {}, - "source": [ - "## Prediction distribution" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-dist", - "metadata": {}, - "outputs": [], - "source": [ - "def count_preds(res):\n", - " s = pd.Series(res['y_pred'])\n", - " return s.value_counts().reindex(LABELS, fill_value=0)\n", - "\n", - "gr_counts = count_preds(gr)\n", - "pr_counts = count_preds(pr)\n", - "gt_counts = pd.Series(gr['y_true']).value_counts().reindex(LABELS, fill_value=0)\n", - "\n", - "x = np.arange(len(LABELS))\n", - "width = 0.25\n", - "\n", - "fig, ax = plt.subplots(figsize=(10, 5))\n", - "ax.bar(x - width, gt_counts, width, label='Ground Truth', color='#4CAF50', edgecolor='white')\n", - "ax.bar(x, gr_counts, width, label=gr['model'], color='#2196F3', edgecolor='white')\n", - "ax.bar(x + width, pr_counts, width, label=pr['model'], color='#FF9800', edgecolor='white')\n", - "\n", - "ax.set_xticks(x)\n", - "ax.set_xticklabels(LABELS)\n", - "ax.set_ylabel('Count')\n", - "ax.set_title('Prediction Distribution vs Ground Truth')\n", - "ax.legend()\n", - "plt.tight_layout()\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "md-verdict", - "metadata": {}, - "source": [ - "## Verdict" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-verdict", - "metadata": {}, - "outputs": [], - "source": [ - "acc_delta = (gr['accuracy'] - pr['accuracy']) * 100\n", - "f1_delta = (gr_f1 - pr_f1) * 100\n", - "lat_delta = gr['avg_latency'] - pr['avg_latency']\n", - "\n", - "winner = gr['model'] if acc_delta >= 0 else pr['model']\n", - "\n", - "print('=' * 55)\n", - "print(f' Winner by accuracy : {winner}')\n", - "print(f' Accuracy delta : {acc_delta:+.2f} pp (GraphRAG - Plain RAG)')\n", - "print(f' Macro F1 delta : {f1_delta:+.2f} pp')\n", - "print(f' Latency delta : {lat_delta:+.2f}s (GraphRAG - Plain RAG)')\n", - "print('=' * 55)\n", - "print()\n", - "if acc_delta > 0:\n", - " print(\n", - " f'GraphRAG outperforms Plain RAG by {acc_delta:.1f} percentage points, '\n", - " 'confirming that the graph-structured context (full abstract reconstruction '\n", - " 'via AQL traversal) and CrossEncoder reranking provide measurable improvements '\n", - " 'over flat vector retrieval โ€” even with the same embedding model and LLM.'\n", - " )\n", - "elif acc_delta < 0:\n", - " print(\n", - " f'Plain RAG outperforms GraphRAG by {abs(acc_delta):.1f} percentage points '\n", - " 'on this sample. This may indicate that graph expansion introduces noise for '\n", - " 'some query types, or that the sample size is insufficient for a conclusive result.'\n", - " )\n", - "else:\n", - " print('Both models perform identically on this sample.')" - ] - } - ] -} diff --git a/Data_Ingestion_KG.ipynb b/Data_Ingestion_KG.ipynb deleted file mode 100644 index 222a43e..0000000 --- a/Data_Ingestion_KG.ipynb +++ /dev/null @@ -1,311 +0,0 @@ -{ - "nbformat": 4, - "nbformat_minor": 5, - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.10.0" - } - }, - "cells": [ - { - "cell_type": "markdown", - "id": "md-title", - "metadata": {}, - "source": [ - "# Knowledge Graph Construction โ€” Data Ingestion\n", - "\n", - "Builds the ArangoDB knowledge graph from PubMedQA. Run this notebook **once**\n", - "before using `GraphRAG.ipynb`.\n", - "\n", - "**Graph schema:**\n", - "- Nodes: `Papers`, `Chunks` (abstract segments), `Concepts` (MeSH terms)\n", - "- Edges: `HAS_CONTEXT` (Paper โ†’ Chunk), `MENTIONS` (Paper โ†’ Concept)\n", - "\n", - "**Two ingestion passes:**\n", - "1. `pqa_unlabeled` โ€” full corpus without ground-truth labels\n", - "2. `pqa_labeled` โ€” 1 000 papers with `final_decision` (yes / no / maybe)\n", - "\n", - "Set `ARANGO_HOST`, `ARANGO_USER`, `ARANGO_PASS` as environment variables\n", - "(or Colab secrets) before running." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-install", - "metadata": {}, - "outputs": [], - "source": [ - "!pip install python-arango sentence-transformers datasets tqdm -q" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-imports", - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "import time\n", - "from arango import ArangoClient\n", - "from sentence_transformers import SentenceTransformer\n", - "from datasets import load_dataset\n", - "from tqdm import tqdm" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-config", - "metadata": {}, - "outputs": [], - "source": [ - "# โ”€โ”€ Credentials โ€” set via environment variables, never hardcode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", - "ARANGO_HOST = os.environ.get('ARANGO_HOST', 'https://bfc25a0e3c74.arangodb.cloud:8529')\n", - "ARANGO_USER = os.environ.get('ARANGO_USER', 'root')\n", - "ARANGO_PASS = os.environ.get('ARANGO_PASS', '') # required โ€” set before running\n", - "DB_NAME = os.environ.get('ARANGO_DB', 'pubmed_graph')\n", - "\n", - "# โ”€โ”€ Graph schema names โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", - "NODE_COLS = ['Papers', 'Chunks', 'Concepts']\n", - "EDGE_COLS = ['HAS_CONTEXT', 'MENTIONS']\n", - "VIEW_NAME = 'pubmed_view'\n", - "\n", - "# โ”€โ”€ Embedding โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", - "EMBEDDING_MODEL = 'sentence-transformers/all-MiniLM-L6-v2' # 384-dim; matches GraphRAG\n", - "BATCH_SIZE = 50\n", - "\n", - "if not ARANGO_PASS:\n", - " raise EnvironmentError(\n", - " 'Set the ARANGO_PASS environment variable before running this notebook.'\n", - " )\n", - "\n", - "print('Configuration loaded.')" - ] - }, - { - "cell_type": "markdown", - "id": "md-connect", - "metadata": {}, - "source": [ - "## 1. Connect and set up graph schema" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-connect", - "metadata": {}, - "outputs": [], - "source": [ - "client = ArangoClient(hosts=ARANGO_HOST)\n", - "sys_db = client.db('_system', username=ARANGO_USER, password=ARANGO_PASS)\n", - "\n", - "if not sys_db.has_database(DB_NAME):\n", - " sys_db.create_database(DB_NAME)\n", - " print(f'Created database: {DB_NAME}')\n", - "else:\n", - " print(f'Using existing database: {DB_NAME}')\n", - "\n", - "db = client.db(DB_NAME, username=ARANGO_USER, password=ARANGO_PASS)\n", - "\n", - "# Node collections\n", - "for col in NODE_COLS:\n", - " if not db.has_collection(col):\n", - " db.create_collection(col)\n", - " print(f' Created node collection: {col}')\n", - "\n", - "# Edge collections\n", - "for col in EDGE_COLS:\n", - " if not db.has_collection(col):\n", - " db.create_collection(col, edge=True)\n", - " print(f' Created edge collection: {col}')\n", - "\n", - "# ArangoSearch view for vector + keyword search\n", - "existing_views = [v['name'] for v in db.views()]\n", - "if VIEW_NAME not in existing_views:\n", - " db.create_arangosearch_view(\n", - " name=VIEW_NAME,\n", - " properties={\n", - " 'links': {\n", - " 'Chunks': {\n", - " 'fields': {\n", - " 'embedding': {'analyzers': ['identity']},\n", - " 'text': {'analyzers': ['text_en']},\n", - " }\n", - " }\n", - " }\n", - " },\n", - " )\n", - " print(f' Created ArangoSearch view: {VIEW_NAME}')\n", - "else:\n", - " print(f' ArangoSearch view already exists: {VIEW_NAME}')\n", - "\n", - "print('\\nSchema ready.')" - ] - }, - { - "cell_type": "markdown", - "id": "md-unlabeled", - "metadata": {}, - "source": [ - "## 2. Ingest `pqa_unlabeled`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-ingest-unlabeled", - "metadata": {}, - "outputs": [], - "source": [ - "def ingest_dataset(db, dataset, model, batch_size=BATCH_SIZE, on_duplicate_paper='ignore'):\n", - " \"\"\"\n", - " Iterates over a PubMedQA dataset and inserts Papers, Chunks, Concepts\n", - " and their edges in batches.\n", - " \"\"\"\n", - " papers_buf = []\n", - " chunks_buf = []\n", - " concepts_buf = []\n", - " has_ctx_buf = [] # HAS_CONTEXT edges\n", - " mentions_buf = [] # MENTIONS edges\n", - " count = 0\n", - "\n", - " def flush():\n", - " if papers_buf:\n", - " db.collection('Papers').import_bulk(papers_buf, on_duplicate=on_duplicate_paper)\n", - " if concepts_buf:\n", - " db.collection('Concepts').import_bulk(concepts_buf, on_duplicate='ignore')\n", - " if chunks_buf:\n", - " db.collection('Chunks').import_bulk(chunks_buf, on_duplicate='ignore')\n", - " if has_ctx_buf:\n", - " db.collection('HAS_CONTEXT').import_bulk(has_ctx_buf, on_duplicate='ignore')\n", - " if mentions_buf:\n", - " db.collection('MENTIONS').import_bulk(mentions_buf, on_duplicate='ignore')\n", - " papers_buf.clear()\n", - " chunks_buf.clear()\n", - " concepts_buf.clear()\n", - " has_ctx_buf.clear()\n", - " mentions_buf.clear()\n", - "\n", - " for row in tqdm(dataset):\n", - " paper_key = str(row['pubid'])\n", - "\n", - " # Paper node\n", - " paper_doc = {\n", - " '_key': paper_key,\n", - " 'title': row['question'],\n", - " 'answer': row['long_answer'],\n", - " }\n", - " if row.get('final_decision'):\n", - " paper_doc['final_decision'] = row['final_decision']\n", - " papers_buf.append(paper_doc)\n", - "\n", - " # Concept (MeSH) nodes + MENTIONS edges\n", - " for mesh in row.get('context', {}).get('meshes', []):\n", - " mesh_key = ''.join(c for c in mesh if c.isalnum())\n", - " if not mesh_key:\n", - " continue\n", - " concepts_buf.append({'_key': mesh_key, 'name': mesh})\n", - " mentions_buf.append({\n", - " '_from': 'Papers/' + paper_key,\n", - " '_to': 'Concepts/' + mesh_key,\n", - " })\n", - "\n", - " # Chunk nodes + HAS_CONTEXT edges\n", - " ctx_texts = row.get('context', {}).get('contexts', [])\n", - " ctx_labels = row.get('context', {}).get('labels', [])\n", - " if ctx_texts:\n", - " embeddings = model.encode(ctx_texts)\n", - " for idx, (text, emb) in enumerate(zip(ctx_texts, embeddings)):\n", - " chunk_key = paper_key + '_' + str(idx)\n", - " chunks_buf.append({\n", - " '_key': chunk_key,\n", - " 'text': text,\n", - " 'label': ctx_labels[idx] if idx < len(ctx_labels) else 'context',\n", - " 'embedding': emb.tolist(),\n", - " })\n", - " has_ctx_buf.append({\n", - " '_from': 'Papers/' + paper_key,\n", - " '_to': 'Chunks/' + chunk_key,\n", - " })\n", - "\n", - " count += 1\n", - " if count % batch_size == 0:\n", - " flush()\n", - "\n", - " flush() # final partial batch\n", - " return count\n", - "\n", - "\n", - "print('Loading model and pqa_unlabeled dataset...')\n", - "model = SentenceTransformer(EMBEDDING_MODEL)\n", - "ds_unlabeled = load_dataset('qiaojin/PubMedQA', 'pqa_unlabeled', split='train')\n", - "\n", - "print(f'Ingesting {len(ds_unlabeled):,} unlabeled papers...')\n", - "t0 = time.time()\n", - "count = ingest_dataset(db, ds_unlabeled, model, on_duplicate_paper='ignore')\n", - "print(f'Done. {count:,} papers in {time.time() - t0:.1f}s.')" - ] - }, - { - "cell_type": "markdown", - "id": "md-labeled", - "metadata": {}, - "source": [ - "## 3. Ingest `pqa_labeled`\n", - "\n", - "These 1 000 papers have ground-truth `final_decision` labels used for evaluation.\n", - "Papers already ingested from `pqa_unlabeled` are updated (not duplicated)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-ingest-labeled", - "metadata": {}, - "outputs": [], - "source": [ - "print('Loading pqa_labeled dataset...')\n", - "ds_labeled = load_dataset('qiaojin/PubMedQA', 'pqa_labeled', split='train')\n", - "\n", - "print(f'Ingesting {len(ds_labeled):,} labeled papers...')\n", - "t0 = time.time()\n", - "count = ingest_dataset(db, ds_labeled, model, on_duplicate_paper='update')\n", - "print(f'Done. {count:,} papers in {time.time() - t0:.1f}s.')" - ] - }, - { - "cell_type": "markdown", - "id": "md-verify", - "metadata": {}, - "source": [ - "## 4. Verify ingestion" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-verify", - "metadata": {}, - "outputs": [], - "source": [ - "for col in NODE_COLS + EDGE_COLS:\n", - " n = db.collection(col).count()\n", - " print(f' {col:<15}: {n:>8,}')\n", - "\n", - "labeled_count = list(db.aql.execute(\n", - " 'FOR p IN Papers FILTER HAS(p, \"final_decision\") COLLECT WITH COUNT INTO n RETURN n'\n", - "))[0]\n", - "print(f'\\n Papers with ground-truth label: {labeled_count:,}')" - ] - } - ] -} diff --git a/GraphRAG.ipynb b/GraphRAG.ipynb deleted file mode 100644 index cf7b778..0000000 --- a/GraphRAG.ipynb +++ /dev/null @@ -1,493 +0,0 @@ -{ - "nbformat": 4, - "nbformat_minor": 5, - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.10.0" - } - }, - "cells": [ - { - "cell_type": "markdown", - "id": "md-title", - "metadata": {}, - "source": [ - "# GraphRAG โ€” Knowledge-Graph-Augmented Retrieval\n", - "\n", - "**Pipeline (what GraphRAG adds over Plain RAG):**\n", - "1. Same `all-MiniLM-L6-v2` encoding as Plain RAG\n", - "2. Wide cosine search โ†’ top-75 candidates (vs top-3 direct in Plain RAG)\n", - "3. CrossEncoder reranking โ†’ top-3\n", - "4. AQL graph traversal reconstructs full paper abstracts (vs raw chunk text)\n", - "5. Same `deepseek-r1:8b` via Ollama\n", - "\n", - "**Controlled variables** (identical in both notebooks): embedding model, LLM, system prompt, answer extraction, evaluation.\n", - "\n", - "Run on **Colab with GPU** for best performance." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-install", - "metadata": {}, - "outputs": [], - "source": "!pip install python-arango sentence-transformers datasets gradio -q\n!apt-get install -y zstd -q\n!curl -fsSL https://ollama.com/install.sh | sh" - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-imports", - "metadata": {}, - "outputs": [], - "source": [ - "import os, re, sys, time, json, pickle, subprocess, requests\n", - "import numpy as np\n", - "import matplotlib.pyplot as plt\n", - "import seaborn as sns\n", - "from sklearn.metrics import accuracy_score, classification_report, confusion_matrix\n", - "from sklearn.metrics.pairwise import cosine_similarity\n", - "from sentence_transformers import SentenceTransformer, CrossEncoder\n", - "from datasets import load_dataset\n", - "from arango import ArangoClient\n", - "from arango.exceptions import ServerConnectionError, ArangoServerError\n", - "from tqdm import tqdm\n", - "import gradio as gr\n", - "print('Imports OK')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-shared", - "metadata": {}, - "outputs": [], - "source": [ - "# โ”€โ”€ Shared constants (word-for-word identical in Plain_RAG.ipynb) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", - "EMBEDDING_MODEL = 'sentence-transformers/all-MiniLM-L6-v2'\n", - "LLM_MODEL = 'deepseek-r1:8b'\n", - "OLLAMA_API = 'http://localhost:11434/api/chat'\n", - "TOP_K_FINAL = 3\n", - "\n", - "BENCHMARK_SYSTEM_PROMPT = (\n", - " 'You are a PubMedQA annotator. Classify the answer as yes, no, or maybe.\\n\\n'\n", - " 'Guidelines:\\n'\n", - " '- YES : the study finds a positive outcome, correlation, or association,\\n'\n", - " ' even if further research is recommended.\\n'\n", - " '- NO : the study finds no significant difference or a negative result.\\n'\n", - " '- MAYBE: only if the abstract explicitly states inconclusive results\\n'\n", - " ' with no supporting data.\\n\\n'\n", - " 'End your response with exactly: Final Answer: [yes/no/maybe]'\n", - ")\n", - "\n", - "CHAT_SYSTEM_PROMPT = (\n", - " 'You are a helpful medical AI assistant. '\n", - " 'Use the provided research abstracts to answer the user question. '\n", - " 'Cite specific study titles when making claims. '\n", - " 'If studies conflict, explain the conflict. '\n", - " 'If the context is insufficient, say so and give your best assessment.'\n", - ")\n", - "\n", - "\n", - "class FuzzyEvaluator:\n", - " \"\"\"Extracts and normalises yes/no/maybe from verbose model output.\"\"\"\n", - " def extract_answer(self, text):\n", - " clean = re.sub(r'.*?', '', text, flags=re.DOTALL).lower()\n", - " m = re.search(r'final answer\\s*:\\s*(yes|no|maybe)', clean)\n", - " if m: return m.group(1)\n", - " hits = re.findall(r'\\b(yes|no|maybe)\\b', clean)\n", - " return hits[-1] if hits else 'maybe'\n", - "\n", - "\n", - "class Evaluator:\n", - " \"\"\"Records predictions and generates a full evaluation report.\"\"\"\n", - " def __init__(self, model_name):\n", - " self.model_name = model_name\n", - " self.y_true, self.y_pred, self.latencies = [], [], []\n", - "\n", - " def record(self, gt, pred, latency=0.0):\n", - " p = pred.lower().strip()\n", - " if p not in ('yes', 'no', 'maybe'): p = 'maybe'\n", - " self.y_true.append(gt.lower().strip())\n", - " self.y_pred.append(p)\n", - " self.latencies.append(latency)\n", - "\n", - " def report(self):\n", - " if not self.y_true: print('No data.'); return {}\n", - " labels = ['yes', 'no', 'maybe']\n", - " acc = accuracy_score(self.y_true, self.y_pred)\n", - " total = sum(self.latencies)\n", - " avg = total / len(self.latencies)\n", - " print(f\"\\n{'='*52}\\n {self.model_name} โ€” Evaluation Report\\n{'='*52}\")\n", - " print(f' Samples : {len(self.y_true)} | Accuracy : {acc:.2%} | Avg latency : {avg:.1f}s')\n", - " print(f\"{'โ”€'*52}\")\n", - " print(classification_report(self.y_true, self.y_pred, labels=labels, zero_division=0))\n", - " cm = confusion_matrix(self.y_true, self.y_pred, labels=labels)\n", - " fig, ax = plt.subplots(figsize=(6, 5))\n", - " sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',\n", - " xticklabels=labels, yticklabels=labels, ax=ax)\n", - " ax.set_xlabel('Predicted'); ax.set_ylabel('Actual')\n", - " ax.set_title(f'Confusion Matrix โ€” {self.model_name}')\n", - " plt.tight_layout(); plt.show()\n", - " return {'model': self.model_name, 'accuracy': acc, 'samples': len(self.y_true),\n", - " 'total_time': total, 'avg_latency': avg,\n", - " 'y_true': self.y_true, 'y_pred': self.y_pred}\n", - "\n", - " def save(self, path):\n", - " data = {'model': self.model_name,\n", - " 'accuracy': accuracy_score(self.y_true, self.y_pred) if self.y_true else 0,\n", - " 'samples': len(self.y_true), 'total_time': sum(self.latencies),\n", - " 'avg_latency': sum(self.latencies)/len(self.latencies) if self.latencies else 0,\n", - " 'y_true': self.y_true, 'y_pred': self.y_pred}\n", - " with open(path, 'w') as f: json.dump(data, f, indent=2)\n", - " print(f'Results saved to {path}')\n", - "\n", - "\n", - "def call_ollama(prompt, system='', temperature=0.0, model=LLM_MODEL):\n", - " messages = []\n", - " if system: messages.append({'role': 'system', 'content': system})\n", - " messages.append({'role': 'user', 'content': prompt})\n", - " payload = {'model': model, 'messages': messages, 'stream': False,\n", - " 'options': {'temperature': temperature, 'num_ctx': 4096}}\n", - " resp = requests.post(OLLAMA_API, json=payload, timeout=300)\n", - " resp.raise_for_status()\n", - " return resp.json()['message']['content']\n", - "\n", - "\n", - "print('Shared utilities ready.')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-config", - "metadata": {}, - "outputs": [], - "source": [ - "# โ”€โ”€ ArangoDB credentials โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", - "# In Colab: add ARANGO_PASS in Secrets (key icon in sidebar)\n", - "try:\n", - " from google.colab import userdata\n", - " ARANGO_PASS = userdata.get('ARANGO_PASS')\n", - "except Exception:\n", - " ARANGO_PASS = os.environ.get('ARANGO_PASS', '')\n", - "\n", - "ARANGO_HOST = os.environ.get('ARANGO_HOST', 'https://bfc25a0e3c74.arangodb.cloud:8529')\n", - "ARANGO_USER = os.environ.get('ARANGO_USER', 'root')\n", - "ARANGO_DB = os.environ.get('ARANGO_DB', 'pubmed_graph')\n", - "\n", - "# โ”€โ”€ GraphRAG-specific parameters โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", - "TOP_K_CANDIDATES = 75\n", - "CROSS_ENCODER = 'cross-encoder/ms-marco-MiniLM-L-6-v2'\n", - "VECTOR_CACHE_FILE = 'pubmed_vectors_cache.pkl'\n", - "RESULTS_DIR = 'results'\n", - "RESULTS_FILE = os.path.join(RESULTS_DIR, 'graphrag_results.json')\n", - "BENCHMARK_N = 100\n", - "\n", - "os.makedirs(RESULTS_DIR, exist_ok=True)\n", - "\n", - "if not ARANGO_PASS:\n", - " raise ValueError('Set ARANGO_PASS in Colab Secrets (key icon) or as an env var.')\n", - "\n", - "print(f'Config ready. BENCHMARK_N={BENCHMARK_N}, results -> {RESULTS_FILE}')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-ollama", - "metadata": {}, - "outputs": [], - "source": "def ensure_ollama(model=LLM_MODEL):\n import shutil, os\n ollama = shutil.which('ollama')\n if not ollama:\n for p in ['/usr/local/bin/ollama', '/usr/bin/ollama',\n os.path.expanduser('~/.ollama/bin/ollama'),\n '/opt/ollama/bin/ollama']:\n if os.path.isfile(p) and os.access(p, os.X_OK):\n ollama = p\n break\n if not ollama:\n try:\n res = subprocess.run(\n ['find', '/usr', os.path.expanduser('~'), '-name', 'ollama', '-type', 'f'],\n capture_output=True, text=True, timeout=15)\n for line in res.stdout.splitlines():\n if line.strip() and os.access(line.strip(), os.X_OK):\n ollama = line.strip()\n break\n except Exception:\n pass\n if not ollama:\n raise FileNotFoundError('ollama binary not found โ€” re-run the install cell.')\n print(f'[Ollama] binary: {ollama}')\n subprocess.Popen([ollama, 'serve'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n time.sleep(3)\n result = subprocess.run([ollama, 'list'], capture_output=True, text=True)\n if model in result.stdout:\n print(f'[Ollama] {model} is ready.')\n return\n print(f'[Ollama] Pulling {model}...')\n subprocess.run([ollama, 'pull', model], check=True)\n print(f'[Ollama] {model} ready.')\nensure_ollama()" - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-arango", - "metadata": {}, - "outputs": [], - "source": [ - "def connect_arango(host, user, password, db_name, max_retries=5):\n", - " client = ArangoClient(hosts=host)\n", - " for attempt in range(max_retries):\n", - " try:\n", - " sys_db = client.db('_system', username=user, password=password)\n", - " sys_db.version()\n", - " db = client.db(db_name, username=user, password=password)\n", - " print('[ArangoDB] Connected.')\n", - " return db\n", - " except (ServerConnectionError, ArangoServerError) as exc:\n", - " wait = (attempt + 1) * 5\n", - " print(f'[ArangoDB] Attempt {attempt+1} failed. Retrying in {wait}s...')\n", - " time.sleep(wait)\n", - " raise ConnectionError('Could not connect to ArangoDB.')\n", - "\n", - "db = connect_arango(ARANGO_HOST, ARANGO_USER, ARANGO_PASS, ARANGO_DB)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-cache", - "metadata": {}, - "outputs": [], - "source": [ - "def load_chunk_vectors(db, collection='Chunks', cache_file=VECTOR_CACHE_FILE):\n", - " if os.path.exists(cache_file):\n", - " print(f'[Cache] Loading from {cache_file}...')\n", - " try:\n", - " with open(cache_file, 'rb') as f: data = pickle.load(f)\n", - " ids, texts, embs = data['ids'], data['texts'], data['embeddings']\n", - " if len(embs) > 0:\n", - " print(f'[Cache] Loaded {len(embs):,} vectors.')\n", - " return ids, texts, np.array(embs)\n", - " except Exception as exc:\n", - " print(f'[Cache] Corrupted ({exc}). Re-downloading...')\n", - "\n", - " print('[Index] Downloading vectors from ArangoDB...')\n", - " ids, texts, embs = [], [], []\n", - " BATCH, offset = 5000, 0\n", - " try: total = list(db.aql.execute(f'RETURN LENGTH({collection})'))[0]\n", - " except: total = 0\n", - "\n", - " with tqdm(total=total, desc='Downloading') as pbar:\n", - " while True:\n", - " aql = f'''\n", - " FOR c IN {collection}\n", - " FILTER c.embedding != null\n", - " LIMIT {offset}, {BATCH}\n", - " RETURN {{ id: c._id, text: c.text, emb: c.embedding }}\n", - " '''\n", - " try: batch = list(db.aql.execute(aql, ttl=3600))\n", - " except Exception as exc:\n", - " print(f'Batch error: {exc}')\n", - " if '503' in str(exc): time.sleep(5)\n", - " break\n", - " if not batch: break\n", - " for doc in batch:\n", - " ids.append(doc['id']); texts.append(doc['text']); embs.append(doc['emb'])\n", - " pbar.update(len(batch)); offset += len(batch)\n", - " if len(batch) < BATCH: break\n", - " time.sleep(0.1)\n", - "\n", - " embs_np = np.array(embs)\n", - " if ids:\n", - " with open(cache_file, 'wb') as f:\n", - " pickle.dump({'ids': ids, 'texts': texts, 'embeddings': embs_np}, f)\n", - " print(f'[Cache] Saved {len(ids):,} vectors.')\n", - " return ids, texts, embs_np" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-graphrag-class", - "metadata": {}, - "outputs": [], - "source": [ - "class GraphRAG:\n", - " \"\"\"\n", - " Graph-augmented RAG. Advantages over Plain RAG:\n", - " - Wide retrieval (top-75) + CrossEncoder reranking\n", - " - AQL traversal reconstructs full coherent abstracts\n", - " - MeSH concept graph links papers semantically\n", - " LLM, embedding model, prompt, and evaluation are identical to Plain RAG.\n", - " \"\"\"\n", - "\n", - " def __init__(self, db, chunk_ids, chunk_texts, chunk_embeddings):\n", - " self.db = db\n", - " self.chunk_ids, self.chunk_texts, self.chunk_embeddings = chunk_ids, chunk_texts, chunk_embeddings\n", - " print('[Model] Loading Sentence Transformer...')\n", - " self.encoder = SentenceTransformer(EMBEDDING_MODEL)\n", - " print('[Model] Loading CrossEncoder...')\n", - " self.reranker = CrossEncoder(CROSS_ENCODER)\n", - " print('[GraphRAG] Initialised.')\n", - "\n", - " def retrieve(self, query):\n", - " if len(self.chunk_embeddings) == 0: return 'No context available.'\n", - " q_emb = self.encoder.encode([query])\n", - " sims = cosine_similarity(q_emb, self.chunk_embeddings)[0]\n", - " top_idx = np.argsort(sims)[-TOP_K_CANDIDATES:][::-1]\n", - " candidates = [(self.chunk_texts[i], self.chunk_ids[i]) for i in top_idx]\n", - " scores = self.reranker.predict([[query, t] for t, _ in candidates])\n", - " best_idx = np.argsort(scores)[::-1][:TOP_K_FINAL]\n", - " return self._expand_via_graph([candidates[i][1] for i in best_idx])\n", - "\n", - " def _expand_via_graph(self, chunk_ids):\n", - " aql = '''\n", - " WITH Papers, Chunks\n", - " FOR cid IN @ids\n", - " LET chunk = DOCUMENT(cid)\n", - " FOR paper IN 1..1 INBOUND chunk HAS_CONTEXT\n", - " LET all_chunks = (FOR c IN 1..1 OUTBOUND paper HAS_CONTEXT RETURN c.text)\n", - " RETURN { title: paper.title, abstract: CONCAT_SEPARATOR(\" \", all_chunks) }\n", - " '''\n", - " try:\n", - " rows, seen, parts = list(self.db.aql.execute(aql, bind_vars={'ids': chunk_ids})), set(), []\n", - " for row in rows:\n", - " t = row.get('title', 'Unknown')\n", - " if t in seen: continue\n", - " seen.add(t)\n", - " parts.append('=== STUDY: ' + t + ' ===\\n' + row.get('abstract', ''))\n", - " return '\\n\\n'.join(parts) if parts else 'No context found.'\n", - " except Exception as exc:\n", - " print(f'[Graph] Expansion failed ({exc}). Using raw chunks.')\n", - " return '\\n\\n'.join('Excerpt: ' + t for t, _ in zip(self.chunk_texts, chunk_ids))\n", - "\n", - " def answer_benchmark(self, question):\n", - " ctx = self.retrieve(question)\n", - " return call_ollama('Context:\\n' + ctx + '\\n\\nQuestion: ' + question,\n", - " system=BENCHMARK_SYSTEM_PROMPT, temperature=0.0)\n", - "\n", - " def answer_chat(self, question):\n", - " ctx = self.retrieve(question)\n", - " raw = call_ollama('Context:\\n' + ctx + '\\n\\nQuestion: ' + question,\n", - " system=CHAT_SYSTEM_PROMPT, temperature=0.3)\n", - " titles = re.findall(r'=== STUDY: (.*?) ===', ctx)\n", - " src = '\\n'.join('- ' + t for t in titles)\n", - " return raw + '\\n\\n**Sources:**\\n' + src if src else raw" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-init", - "metadata": {}, - "outputs": [], - "source": [ - "chunk_ids, chunk_texts, chunk_embeddings = load_chunk_vectors(db)\n", - "rag = GraphRAG(db, chunk_ids, chunk_texts, chunk_embeddings)" - ] - }, - { - "cell_type": "markdown", - "id": "md-benchmark", - "metadata": {}, - "source": [ - "## Benchmark Evaluation\n", - "Evaluates on `pqa_labeled` (1 000 ground-truth samples). Adjust `BENCHMARK_N` in the config cell." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-benchmark", - "metadata": {}, - "outputs": [], - "source": [ - "dataset = load_dataset('qiaojin/PubMedQA', 'pqa_labeled', split='train')\n", - "fuzzy = FuzzyEvaluator()\n", - "evaluator = Evaluator('GraphRAG')\n", - "\n", - "print(f'=== GraphRAG Benchmark (n={BENCHMARK_N}) ===')\n", - "for i, item in enumerate(dataset):\n", - " if i >= BENCHMARK_N: break\n", - " t0 = time.time()\n", - " raw = rag.answer_benchmark(item['question'])\n", - " lat = time.time() - t0\n", - " pred = fuzzy.extract_answer(raw)\n", - " gt = item['final_decision']\n", - " evaluator.record(gt, pred, lat)\n", - " print(f'[{i+1:3d}] GT={gt:<5} Pred={pred:<5} {\"v\" if pred==gt else \"x\"} ({lat:.1f}s)')\n", - "\n", - "results = evaluator.report()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-save", - "metadata": {}, - "outputs": [], - "source": [ - "evaluator.save(RESULTS_FILE)" - ] - }, - { - "cell_type": "markdown", - "id": "md-compare", - "metadata": {}, - "source": [ - "## Head-to-Head Comparison\n", - "Run after both notebooks have saved results. Also available as a standalone `Comparison.ipynb`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-compare", - "metadata": {}, - "outputs": [], - "source": [ - "from sklearn.metrics import f1_score\n", - "import pandas as pd\n", - "\n", - "plainrag_path = os.path.join(RESULTS_DIR, 'plainrag_results.json')\n", - "if not os.path.exists(plainrag_path):\n", - " print('Run Plain_RAG.ipynb first, then re-run this cell.')\n", - "else:\n", - " with open(RESULTS_FILE) as f: gr_r = json.load(f)\n", - " with open(plainrag_path) as f: pr_r = json.load(f)\n", - " LABELS = ['yes', 'no', 'maybe']\n", - " models = [gr_r['model'], pr_r['model']]\n", - " accs = [gr_r['accuracy']*100, pr_r['accuracy']*100]\n", - " f1s = [f1_score(r['y_true'], r['y_pred'], labels=LABELS, average='macro', zero_division=0)*100\n", - " for r in [gr_r, pr_r]]\n", - " lats = [gr_r['avg_latency'], pr_r['avg_latency']]\n", - " cols = ['#2196F3', '#FF9800']\n", - "\n", - " fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n", - " for ax, vals, lbl in zip(axes, [accs, f1s, lats],\n", - " ['Accuracy (%)', 'Macro F1 (%)', 'Avg Latency (s)']):\n", - " bars = ax.bar(models, vals, color=cols, width=0.4, edgecolor='white')\n", - " ax.set_title(lbl); ax.set_ylabel(lbl)\n", - " if 'Latency' not in lbl: ax.set_ylim(0, 100)\n", - " for b, v in zip(bars, vals):\n", - " ax.text(b.get_x()+b.get_width()/2, v*1.03 if 'Lat' in lbl else v+1.5,\n", - " f'{v:.1f}', ha='center', fontweight='bold')\n", - " plt.suptitle(f'GraphRAG vs Plain RAG (n={gr_r[\"samples\"]} samples)',\n", - " fontsize=13, fontweight='bold', y=1.02)\n", - " plt.tight_layout(); plt.show()\n", - "\n", - " delta = accs[0] - accs[1]\n", - " winner = models[0] if delta >= 0 else models[1]\n", - " print(f'\\n{winner} wins by {abs(delta):.1f} pp (accuracy delta: {delta:+.1f} pp)')" - ] - }, - { - "cell_type": "markdown", - "id": "md-ui", - "metadata": {}, - "source": [ - "## Interactive Chat UI" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-ui", - "metadata": {}, - "outputs": [], - "source": [ - "def launch_ui(rag_instance):\n", - " def chat_fn(message, history): return rag_instance.answer_chat(message)\n", - " gr.ChatInterface(\n", - " fn=chat_fn, title='PubMed GraphRAG Assistant',\n", - " description='Graph-augmented retrieval: full abstracts reconstructed via AQL traversal.',\n", - " examples=['Do preoperative statins reduce atrial fibrillation?',\n", - " 'Is obesity a risk factor for cirrhosis-related death?',\n", - " 'Does high-dose aspirin prevent cardiovascular events?'],\n", - " theme='soft',\n", - " ).launch(share=True, debug=True)\n", - "\n", - "launch_ui(rag)" - ] - } - ] -} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a4d22a6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The Knowledge Graph Question Answering Project Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Plain_RAG/Plain_RAG.ipynb b/Plain_RAG/Plain_RAG.ipynb deleted file mode 100644 index 8d894da..0000000 --- a/Plain_RAG/Plain_RAG.ipynb +++ /dev/null @@ -1,432 +0,0 @@ -{ - "nbformat": 4, - "nbformat_minor": 5, - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.10.0" - } - }, - "cells": [ - { - "cell_type": "markdown", - "id": "md-title", - "metadata": {}, - "source": [ - "# Plain RAG โ€” Flat FAISS Vector Baseline\n", - "\n", - "**Pipeline (the baseline that GraphRAG competes against):**\n", - "1. `all-MiniLM-L6-v2` encoding _(same model as GraphRAG)_\n", - "2. FAISS `IndexFlatIP` โ€” direct top-3 nearest-neighbour search, no reranking\n", - "3. Concatenated raw chunk texts as context (no graph expansion)\n", - "4. `deepseek-r1:8b` via Ollama _(same model as GraphRAG)_\n", - "\n", - "**Controlled variables** (identical in both notebooks): embedding model, LLM, system prompt, answer extraction, evaluation.\n", - "\n", - "Run on **Colab with GPU** โ€” change `faiss-cpu` to `faiss-gpu-cu12` in the install cell for ~40ร— faster index build." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-install", - "metadata": {}, - "outputs": [], - "source": "# On Colab with GPU: replace faiss-cpu with faiss-gpu-cu12\n# !pip install faiss-cpu sentence-transformers datasets gradio -q\n!pip install faiss-gpu-cu12 sentence-transformers datasets gradio -q\n!apt-get install -y zstd -q\n!curl -fsSL https://ollama.com/install.sh | sh" - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-imports", - "metadata": {}, - "outputs": [], - "source": [ - "import os, re, sys, time, json, pickle, subprocess, requests\n", - "import numpy as np\n", - "import faiss\n", - "import matplotlib.pyplot as plt\n", - "import seaborn as sns\n", - "from sklearn.metrics import accuracy_score, classification_report, confusion_matrix\n", - "from sentence_transformers import SentenceTransformer\n", - "from datasets import load_dataset\n", - "from tqdm import tqdm\n", - "import gradio as gr\n", - "\n", - "try:\n", - " import torch\n", - " HAS_CUDA = torch.cuda.is_available()\n", - "except ImportError:\n", - " HAS_CUDA = False\n", - "\n", - "print('Imports OK | GPU:', HAS_CUDA)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-shared", - "metadata": {}, - "outputs": [], - "source": [ - "# โ”€โ”€ Shared constants (word-for-word identical in GraphRAG.ipynb) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", - "EMBEDDING_MODEL = 'sentence-transformers/all-MiniLM-L6-v2'\n", - "LLM_MODEL = 'deepseek-r1:8b'\n", - "OLLAMA_API = 'http://localhost:11434/api/chat'\n", - "TOP_K_FINAL = 3\n", - "\n", - "BENCHMARK_SYSTEM_PROMPT = (\n", - " 'You are a PubMedQA annotator. Classify the answer as yes, no, or maybe.\\n\\n'\n", - " 'Guidelines:\\n'\n", - " '- YES : the study finds a positive outcome, correlation, or association,\\n'\n", - " ' even if further research is recommended.\\n'\n", - " '- NO : the study finds no significant difference or a negative result.\\n'\n", - " '- MAYBE: only if the abstract explicitly states inconclusive results\\n'\n", - " ' with no supporting data.\\n\\n'\n", - " 'End your response with exactly: Final Answer: [yes/no/maybe]'\n", - ")\n", - "\n", - "CHAT_SYSTEM_PROMPT = (\n", - " 'You are a helpful medical AI assistant. '\n", - " 'Use the provided research abstracts to answer the user question. '\n", - " 'Cite specific study titles when making claims. '\n", - " 'If studies conflict, explain the conflict. '\n", - " 'If the context is insufficient, say so and give your best assessment.'\n", - ")\n", - "\n", - "\n", - "class FuzzyEvaluator:\n", - " \"\"\"Extracts and normalises yes/no/maybe from verbose model output.\"\"\"\n", - " def extract_answer(self, text):\n", - " clean = re.sub(r'.*?', '', text, flags=re.DOTALL).lower()\n", - " m = re.search(r'final answer\\s*:\\s*(yes|no|maybe)', clean)\n", - " if m: return m.group(1)\n", - " hits = re.findall(r'\\b(yes|no|maybe)\\b', clean)\n", - " return hits[-1] if hits else 'maybe'\n", - "\n", - "\n", - "class Evaluator:\n", - " \"\"\"Records predictions and generates a full evaluation report.\"\"\"\n", - " def __init__(self, model_name):\n", - " self.model_name = model_name\n", - " self.y_true, self.y_pred, self.latencies = [], [], []\n", - "\n", - " def record(self, gt, pred, latency=0.0):\n", - " p = pred.lower().strip()\n", - " if p not in ('yes', 'no', 'maybe'): p = 'maybe'\n", - " self.y_true.append(gt.lower().strip())\n", - " self.y_pred.append(p)\n", - " self.latencies.append(latency)\n", - "\n", - " def report(self):\n", - " if not self.y_true: print('No data.'); return {}\n", - " labels = ['yes', 'no', 'maybe']\n", - " acc = accuracy_score(self.y_true, self.y_pred)\n", - " total = sum(self.latencies)\n", - " avg = total / len(self.latencies)\n", - " print(f\"\\n{'='*52}\\n {self.model_name} โ€” Evaluation Report\\n{'='*52}\")\n", - " print(f' Samples : {len(self.y_true)} | Accuracy : {acc:.2%} | Avg latency : {avg:.1f}s')\n", - " print(f\"{'โ”€'*52}\")\n", - " print(classification_report(self.y_true, self.y_pred, labels=labels, zero_division=0))\n", - " cm = confusion_matrix(self.y_true, self.y_pred, labels=labels)\n", - " fig, ax = plt.subplots(figsize=(6, 5))\n", - " sns.heatmap(cm, annot=True, fmt='d', cmap='Oranges',\n", - " xticklabels=labels, yticklabels=labels, ax=ax)\n", - " ax.set_xlabel('Predicted'); ax.set_ylabel('Actual')\n", - " ax.set_title(f'Confusion Matrix โ€” {self.model_name}')\n", - " plt.tight_layout(); plt.show()\n", - " return {'model': self.model_name, 'accuracy': acc, 'samples': len(self.y_true),\n", - " 'total_time': total, 'avg_latency': avg,\n", - " 'y_true': self.y_true, 'y_pred': self.y_pred}\n", - "\n", - " def save(self, path):\n", - " data = {'model': self.model_name,\n", - " 'accuracy': accuracy_score(self.y_true, self.y_pred) if self.y_true else 0,\n", - " 'samples': len(self.y_true), 'total_time': sum(self.latencies),\n", - " 'avg_latency': sum(self.latencies)/len(self.latencies) if self.latencies else 0,\n", - " 'y_true': self.y_true, 'y_pred': self.y_pred}\n", - " with open(path, 'w') as f: json.dump(data, f, indent=2)\n", - " print(f'Results saved to {path}')\n", - "\n", - "\n", - "def call_ollama(prompt, system='', temperature=0.0, model=LLM_MODEL):\n", - " messages = []\n", - " if system: messages.append({'role': 'system', 'content': system})\n", - " messages.append({'role': 'user', 'content': prompt})\n", - " payload = {'model': model, 'messages': messages, 'stream': False,\n", - " 'options': {'temperature': temperature, 'num_ctx': 4096}}\n", - " resp = requests.post(OLLAMA_API, json=payload, timeout=300)\n", - " resp.raise_for_status()\n", - " return resp.json()['message']['content']\n", - "\n", - "\n", - "print('Shared utilities ready.')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-config", - "metadata": {}, - "outputs": [], - "source": [ - "# โ”€โ”€ Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", - "INDEX_FILE = 'pubmed_rag_index.bin'\n", - "DATA_FILE = 'pubmed_rag_data.pkl'\n", - "RESULTS_DIR = '../results'\n", - "RESULTS_FILE = os.path.join(RESULTS_DIR, 'plainrag_results.json')\n", - "BENCHMARK_N = 100 # must match GraphRAG.ipynb BENCHMARK_N for a fair comparison\n", - "\n", - "os.makedirs(RESULTS_DIR, exist_ok=True)\n", - "print(f'Config ready. Results -> {RESULTS_FILE}')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-ollama", - "metadata": {}, - "outputs": [], - "source": "def ensure_ollama(model=LLM_MODEL):\n import shutil, os\n ollama = shutil.which('ollama')\n if not ollama:\n for p in ['/usr/local/bin/ollama', '/usr/bin/ollama',\n os.path.expanduser('~/.ollama/bin/ollama'),\n '/opt/ollama/bin/ollama']:\n if os.path.isfile(p) and os.access(p, os.X_OK):\n ollama = p\n break\n if not ollama:\n try:\n res = subprocess.run(\n ['find', '/usr', os.path.expanduser('~'), '-name', 'ollama', '-type', 'f'],\n capture_output=True, text=True, timeout=15)\n for line in res.stdout.splitlines():\n if line.strip() and os.access(line.strip(), os.X_OK):\n ollama = line.strip()\n break\n except Exception:\n pass\n if not ollama:\n raise FileNotFoundError('ollama binary not found โ€” re-run the install cell.')\n print(f'[Ollama] binary: {ollama}')\n subprocess.Popen([ollama, 'serve'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n time.sleep(3)\n result = subprocess.run([ollama, 'list'], capture_output=True, text=True)\n if model in result.stdout:\n print(f'[Ollama] {model} is ready.')\n return\n print(f'[Ollama] Pulling {model} (~5 GB, may take a few minutes)...')\n subprocess.run([ollama, 'pull', model], check=True)\n print(f'[Ollama] {model} ready.')\nensure_ollama()" - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-plainrag-class", - "metadata": {}, - "outputs": [], - "source": [ - "class PlainRAG:\n", - " \"\"\"\n", - " Flat FAISS retrieval baseline. No graph, no reranking.\n", - "\n", - " Deliberate differences from GraphRAG:\n", - " - No wide candidate pool or CrossEncoder reranking\n", - " - No AQL graph traversal โ€” context is raw concatenated chunk text\n", - " - FAISS IndexFlatIP (not ArangoDB vector search)\n", - "\n", - " LLM, embedding model, prompt, and evaluation are identical to GraphRAG.\n", - " \"\"\"\n", - "\n", - " def __init__(self):\n", - " device = 'cuda' if HAS_CUDA else 'cpu'\n", - " print(f'[PlainRAG] Initialising on {device}')\n", - " print('[Model] Loading Sentence Transformer...')\n", - " self.encoder = SentenceTransformer(EMBEDDING_MODEL, device=device)\n", - " self.dim = self.encoder.get_sentence_embedding_dimension()\n", - " self.index = None\n", - " self.texts = []\n", - " print('[PlainRAG] Initialised.')\n", - "\n", - " def load_data(self, index_file=INDEX_FILE, data_file=DATA_FILE):\n", - " if os.path.exists(index_file) and os.path.exists(data_file):\n", - " print('[Index] Loading cached FAISS index...')\n", - " idx_cpu = faiss.read_index(index_file)\n", - " if HAS_CUDA and hasattr(faiss, 'StandardGpuResources'):\n", - " try:\n", - " res = faiss.StandardGpuResources()\n", - " self.index = faiss.index_cpu_to_gpu(res, 0, idx_cpu)\n", - " print('[Index] Moved to GPU.')\n", - " except Exception as e:\n", - " print(f'[Index] GPU failed ({e}), using CPU.')\n", - " self.index = idx_cpu\n", - " else:\n", - " self.index = idx_cpu\n", - " with open(data_file, 'rb') as f: self.texts = pickle.load(f)\n", - " print(f'[Index] Loaded {len(self.texts):,} docs.')\n", - " return\n", - "\n", - " # Build from pqa_unlabeled (61k docs) + pqa_artificial (211k)\n", - " # On Colab T4: ~5 min total. On CPU: 3+ hours.\n", - " print('[Index] Building FAISS index โ€” ~5 min on GPU, 3+ hours on CPU.')\n", - " texts = []\n", - " for split in ['pqa_unlabeled', 'pqa_artificial']:\n", - " ds = load_dataset('qiaojin/PubMedQA', split, split='train')\n", - " for item in tqdm(ds, desc=f'Loading {split}'):\n", - " ctx = item.get('context', {})\n", - " parts = ctx.get('contexts', [])\n", - " text = ' '.join(parts) if parts else item.get('question', '')\n", - " texts.append(text[:2048])\n", - "\n", - " idx_cpu = faiss.IndexFlatIP(self.dim)\n", - " for start in tqdm(range(0, len(texts), 256), desc='Embedding'):\n", - " batch = texts[start:start + 256]\n", - " embs = self.encoder.encode(batch, normalize_embeddings=True,\n", - " show_progress_bar=False, convert_to_numpy=True)\n", - " idx_cpu.add(np.array(embs, dtype=np.float32))\n", - "\n", - " self.texts = texts\n", - " faiss.write_index(idx_cpu, index_file)\n", - " with open(data_file, 'wb') as f: pickle.dump(texts, f)\n", - " print(f'[Index] Built and saved {len(texts):,} docs.')\n", - "\n", - " if HAS_CUDA and hasattr(faiss, 'StandardGpuResources'):\n", - " try:\n", - " res = faiss.StandardGpuResources()\n", - " self.index = faiss.index_cpu_to_gpu(res, 0, idx_cpu)\n", - " except Exception:\n", - " self.index = idx_cpu\n", - " else:\n", - " self.index = idx_cpu\n", - "\n", - " def retrieve(self, query):\n", - " if self.index is None or self.index.ntotal == 0: return 'No context available.'\n", - " q_emb = self.encoder.encode([query], normalize_embeddings=True, convert_to_numpy=True)\n", - " _, idx = self.index.search(np.array(q_emb, dtype=np.float32), TOP_K_FINAL)\n", - " return '\\n\\n'.join(\n", - " f'Abstract {i+1}: {self.texts[j]}' for i, j in enumerate(idx[0]) if j >= 0\n", - " )\n", - "\n", - " def answer_benchmark(self, question):\n", - " ctx = self.retrieve(question)\n", - " return call_ollama('Context:\\n' + ctx + '\\n\\nQuestion: ' + question,\n", - " system=BENCHMARK_SYSTEM_PROMPT, temperature=0.0)\n", - "\n", - " def answer_chat(self, question):\n", - " ctx = self.retrieve(question)\n", - " return call_ollama('Context:\\n' + ctx + '\\n\\nQuestion: ' + question,\n", - " system=CHAT_SYSTEM_PROMPT, temperature=0.3)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-init", - "metadata": {}, - "outputs": [], - "source": [ - "rag = PlainRAG()\n", - "rag.load_data()" - ] - }, - { - "cell_type": "markdown", - "id": "md-benchmark", - "metadata": {}, - "source": [ - "## Benchmark Evaluation\n", - "Evaluates on `pqa_labeled` (1 000 ground-truth samples). Adjust `BENCHMARK_N` in the config cell.\n", - "\n", - "Uses the same `FuzzyEvaluator` and `Evaluator` as `GraphRAG.ipynb` โ€” controlled comparison." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-benchmark", - "metadata": {}, - "outputs": [], - "source": [ - "dataset = load_dataset('qiaojin/PubMedQA', 'pqa_labeled', split='train')\n", - "fuzzy = FuzzyEvaluator()\n", - "evaluator = Evaluator('Plain RAG')\n", - "\n", - "print(f'=== Plain RAG Benchmark (n={BENCHMARK_N}) ===')\n", - "for i, item in enumerate(dataset):\n", - " if i >= BENCHMARK_N: break\n", - " t0 = time.time()\n", - " raw = rag.answer_benchmark(item['question'])\n", - " lat = time.time() - t0\n", - " pred = fuzzy.extract_answer(raw)\n", - " gt = item['final_decision']\n", - " evaluator.record(gt, pred, lat)\n", - " print(f'[{i+1:3d}] GT={gt:<5} Pred={pred:<5} {\"v\" if pred==gt else \"x\"} ({lat:.1f}s)')\n", - "\n", - "results = evaluator.report()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-save", - "metadata": {}, - "outputs": [], - "source": [ - "evaluator.save(RESULTS_FILE)" - ] - }, - { - "cell_type": "markdown", - "id": "md-compare", - "metadata": {}, - "source": [ - "## Head-to-Head Comparison\n", - "Run after both notebooks have saved results. Also available as a standalone `Comparison.ipynb`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-compare", - "metadata": {}, - "outputs": [], - "source": [ - "from sklearn.metrics import f1_score\n", - "\n", - "graphrag_path = os.path.join(RESULTS_DIR, 'graphrag_results.json')\n", - "if not os.path.exists(graphrag_path):\n", - " print('Run GraphRAG.ipynb first, then re-run this cell.')\n", - "else:\n", - " with open(graphrag_path) as f: gr_r = json.load(f)\n", - " with open(RESULTS_FILE) as f: pr_r = json.load(f)\n", - " LABELS = ['yes', 'no', 'maybe']\n", - " models = [gr_r['model'], pr_r['model']]\n", - " accs = [gr_r['accuracy']*100, pr_r['accuracy']*100]\n", - " f1s = [f1_score(r['y_true'], r['y_pred'], labels=LABELS, average='macro', zero_division=0)*100\n", - " for r in [gr_r, pr_r]]\n", - " lats = [gr_r['avg_latency'], pr_r['avg_latency']]\n", - " cols = ['#2196F3', '#FF9800']\n", - "\n", - " fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n", - " for ax, vals, lbl in zip(axes, [accs, f1s, lats],\n", - " ['Accuracy (%)', 'Macro F1 (%)', 'Avg Latency (s)']):\n", - " bars = ax.bar(models, vals, color=cols, width=0.4, edgecolor='white')\n", - " ax.set_title(lbl); ax.set_ylabel(lbl)\n", - " if 'Latency' not in lbl: ax.set_ylim(0, 100)\n", - " for b, v in zip(bars, vals):\n", - " ax.text(b.get_x()+b.get_width()/2, v*1.03 if 'Lat' in lbl else v+1.5,\n", - " f'{v:.1f}', ha='center', fontweight='bold')\n", - " plt.suptitle(f'GraphRAG vs Plain RAG (n={gr_r[\"samples\"]} samples)',\n", - " fontsize=13, fontweight='bold', y=1.02)\n", - " plt.tight_layout(); plt.show()\n", - "\n", - " delta = accs[0] - accs[1]\n", - " winner = models[0] if delta >= 0 else models[1]\n", - " print(f'\\n{winner} wins by {abs(delta):.1f} pp (accuracy delta: {delta:+.1f} pp)')" - ] - }, - { - "cell_type": "markdown", - "id": "md-ui", - "metadata": {}, - "source": [ - "## Interactive Chat UI" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cell-ui", - "metadata": {}, - "outputs": [], - "source": [ - "def launch_ui(rag_instance):\n", - " def chat_fn(message, history): return rag_instance.answer_chat(message)\n", - " gr.ChatInterface(\n", - " fn=chat_fn, title='PubMed Plain RAG Assistant',\n", - " description='Flat FAISS retrieval: direct top-3 nearest-neighbour search over chunk embeddings.',\n", - " examples=['Do preoperative statins reduce atrial fibrillation?',\n", - " 'Is obesity a risk factor for cirrhosis-related death?',\n", - " 'Does high-dose aspirin prevent cardiovascular events?'],\n", - " theme='soft',\n", - " ).launch(share=True, debug=True)\n", - "\n", - "launch_ui(rag)" - ] - } - ] -} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..63011ef --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# Knowledge Graph Question Answering โ€” GraphRAG vs PlainRAG, done fairly + +A controlled study of **what a knowledge graph actually contributes** to +retrieval-augmented question answering on biomedical literature +([PubMedQA](https://pubmedqa.github.io/)). + +Most "GraphRAG beats RAG" demos are confounded: the graph pipeline quietly also +gets a reranker, a different corpus, or โ€” worst of all โ€” leaks the answer into +the prompt. This repo throws those out and runs a **4-arm ablation** where every +layer is held constant and the *only* thing that changes is how much graph +structure the retriever uses. + +``` +plain โ”€โ–บ plain_rr โ”€โ–บ graph โ”€โ–บ graph_concepts + (RAG) (+rerank) (+parent (+MeSH concept + expansion) hop) +``` + +Same corpus, same chunking, same embedder, same reranker, same prompt, same LLM, +same seeded sample, same top-k. The accuracy delta between adjacent arms is +attributable to exactly one component, and we report a **paired McNemar test** so +you can tell a real effect from noise. + +--- + +## Why the original comparison was unfair (and what changed) + +This started from a working but confounded notebook comparison. The audit found +six issues; all are fixed in this revamp: + +| # | Flaw (before) | Fix (now) | +| --- | --- | --- | +| 1 | GraphRAG had a cross-encoder reranker; PlainRAG was raw FAISS top-3 | The reranker is its **own arm** (`plain_rr`). The graph arms build *on top of* `plain_rr`, so the rerank is controlled for, not a hidden advantage | +| 2 | The two pipelines indexed **different corpora** | All arms search one shared `ChunkStore` (labeled + unlabeled, identical chunks) | +| 3 | Different granularity (whole abstracts vs per-section chunks) | Identical per-section chunking for every arm | +| 4 | **Label leakage**: papers stored `title = question` and `final_decision`, injected into the prompt as `=== STUDY: {title} ===` | Ingestion stores **no** question-derived title and **no** `final_decision`; graph context uses generic `=== STUDY n ===` labels with abstracts only. A unit test asserts the question never appears in the context | +| 5 | `Concepts` (MeSH) and `MENTIONS` edges were built but **never used** | The `graph_concepts` arm hops across shared MeSH concepts to pull in related papers | +| 6 | `NameError` in the graph fallback; first-100 samples, no seed, no significance test | Fixed fallback; seeded random sample (default n=200); paired McNemar test | + +**Honest expectation.** PubMedQA is mostly single-abstract QA, so a fair +parent-expansion gain may be modest. The interesting signal is in the +`graph_concepts` arm and in multi-evidence questions โ€” that is where a graph can +legitimately beat plain retrieval. The point of this repo is to measure that +honestly, not to manufacture a win. + +--- + +## Repository layout + +``` +src/kgqa/ importable package โ€” single source of truth + config.py all shared constants (models, top-k, seed, n) + prompts.py benchmark/chat prompts (identical across arms) + llm.py Ollama client + data.py seeded sampling + canonical chunking + evaluation.py answer extraction, metrics, McNemar test + models.py encoder / reranker / ArangoDB loaders + retrieval/ + base.py ChunkStore + BaseRetriever (encodeโ†’rerankโ†’select) + plain.py plain, plain_rr arms + graph.py graph, graph_concepts arms +scripts/ + ingest.py build the leakage-free graph in ArangoDB (run once) + run_benchmark.py run one arm: --arm {plain,plain_rr,graph,graph_concepts} + compare.py summary table + McNemar + ablation figure +notebooks/ + 01_ingest.ipynb thin Colab wrapper for ingestion + 02_benchmark.ipynb thin Colab wrapper for all arms + comparison +tests/ pytest suite (runs on CPU, no Ollama/ArangoDB needed) +docs/ project report (PDF) and slides (PPTX) +``` + +## Stack + +- **Dataset:** PubMedQA (`pqa_labeled` for evaluation, `pqa_unlabeled` for corpus) +- **Embeddings:** `all-MiniLM-L6-v2` (384-dim) +- **Reranker:** `cross-encoder/ms-marco-MiniLM-L-6-v2` +- **Graph DB:** ArangoDB Oasis (Papers / Chunks / Concepts; HAS_CONTEXT / MENTIONS) +- **LLM:** `deepseek-r1:8b` via [Ollama](https://ollama.com) + +--- + +## Setup + +```bash +pip install -r requirements.txt # add -r requirements-dev.txt for tests +cp .env.example .env # then fill in ARANGO_PASS +``` + +Secrets are read from the environment (or a local `.env`, or Colab Secrets): +`ARANGO_HOST`, `ARANGO_USER`, `ARANGO_PASS`, `ARANGO_DB`. Nothing is hardcoded. + +## Running the benchmark + +The graph lives in ArangoDB Oasis and the LLM runs on a GPU, so the benchmark is +designed to run on **Google Colab (T4)** โ€” open the notebooks below. To run +locally you need a reachable ArangoDB and a running Ollama. + +```bash +# 1. Build the graph once +python scripts/ingest.py + +# 2. Run each arm (downloads + caches the shared chunk corpus on first run) +python scripts/run_benchmark.py --arm plain --n 200 +python scripts/run_benchmark.py --arm plain_rr --n 200 +python scripts/run_benchmark.py --arm graph --n 200 +python scripts/run_benchmark.py --arm graph_concepts --n 200 + +# 3. Summary table, McNemar tests, ablation figure -> results/ +python scripts/compare.py +``` + +On Colab, run [`notebooks/01_ingest.ipynb`](notebooks/01_ingest.ipynb) once, then +[`notebooks/02_benchmark.ipynb`](notebooks/02_benchmark.ipynb). + +--- + +## Results + +> โณ **Pending the Colab benchmark run.** `scripts/compare.py` regenerates the +> table below into `results/summary.md` and `results/ablation.png`. + +| Arm | Accuracy | Macro F1 | What it isolates | +| --- | --- | --- | --- | +| `plain` | โ€” | โ€” | baseline RAG | +| `plain_rr` | โ€” | โ€” | + reranker | +| `graph` | โ€” | โ€” | + parent-paper expansion | +| `graph_concepts` | โ€” | โ€” | + MeSH concept hop | + +Paired McNemar tests (reranker effect, parent-expansion effect, concept-hop +effect) are written alongside the table. + +--- + +## Development + +```bash +pytest # 17 tests, all CPU, no external services +ruff check src scripts tests +``` + +CI runs ruff + pytest on every push/PR (Python 3.10 and 3.11). Unit tests inject +fakes for the encoder, reranker, and ArangoDB, so the heavy ML dependencies are +never needed just to verify the logic. + +## License + +[MIT](LICENSE). diff --git a/Graph_RAG_PPT.pptx b/docs/Graph_RAG_PPT.pptx similarity index 100% rename from Graph_RAG_PPT.pptx rename to docs/Graph_RAG_PPT.pptx diff --git a/Project Report.pdf b/docs/Project_Report.pdf similarity index 100% rename from Project Report.pdf rename to docs/Project_Report.pdf diff --git a/notebooks/01_ingest.ipynb b/notebooks/01_ingest.ipynb new file mode 100644 index 0000000..d9d7801 --- /dev/null +++ b/notebooks/01_ingest.ipynb @@ -0,0 +1,65 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 01 โ€” Ingest PubMedQA into ArangoDB (run once)\n", + "\n", + "Thin Colab wrapper around `scripts/ingest.py`. Builds the **leakage-free** knowledge graph\n", + "(Papers / Chunks / Concepts + HAS_CONTEXT / MENTIONS edges).\n", + "\n", + "**Before running:** add `ARANGO_PASS` (and optionally `ARANGO_HOST`) in the Colab\n", + "**Secrets** panel (key icon, left sidebar). Runtime type can be CPU โ€” ingestion is embedding-bound\n", + "but a GPU runtime makes it faster.\n", + "\n", + "Run this notebook **once**, then use `02_benchmark.ipynb`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!git clone https://github.com/vardhjain/Knowledge_Graph_Question_Answering.git\n", + "%cd Knowledge_Graph_Question_Answering\n", + "!git checkout revamp\n", + "!pip install -q -r requirements.txt" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "from google.colab import userdata\n", + "\n", + "os.environ['ARANGO_PASS'] = userdata.get('ARANGO_PASS')\n", + "# Optional overrides if your deployment differs from the defaults:\n", + "# os.environ['ARANGO_HOST'] = userdata.get('ARANGO_HOST')\n", + "# os.environ['ARANGO_DB'] = 'pubmed_graph'\n", + "print('ARANGO_PASS set:', bool(os.environ.get('ARANGO_PASS')))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Full ingestion (labeled + unlabeled). Add --no-unlabeled for a quick smoke test.\n", + "!python scripts/ingest.py" + ] + } + ], + "metadata": { + "colab": {"provenance": []}, + "kernelspec": {"display_name": "Python 3", "name": "python3"}, + "language_info": {"name": "python"} + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/notebooks/02_benchmark.ipynb b/notebooks/02_benchmark.ipynb new file mode 100644 index 0000000..65b3e63 --- /dev/null +++ b/notebooks/02_benchmark.ipynb @@ -0,0 +1,111 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 02 โ€” Benchmark: 4-arm GraphRAG vs PlainRAG ablation\n", + "\n", + "Thin Colab wrapper around `scripts/run_benchmark.py` and `scripts/compare.py`.\n", + "\n", + "**Use a GPU runtime** (Runtime โ†’ Change runtime type โ†’ T4 GPU). Add `ARANGO_PASS`\n", + "in the Colab **Secrets** panel. Run `01_ingest.ipynb` first.\n", + "\n", + "Arms (each isolates one component):\n", + "\n", + "| arm | adds |\n", + "| --- | --- |\n", + "| `plain` | vector top-k chunks (baseline) |\n", + "| `plain_rr` | + cross-encoder reranker |\n", + "| `graph` | + parent-paper expansion (full abstracts) |\n", + "| `graph_concepts` | + MeSH concept-hop expansion |\n", + "\n", + "Full run is ~2โ€“4 hrs (LLM-bound). Lower `--n` for a faster pass." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!git clone https://github.com/vardhjain/Knowledge_Graph_Question_Answering.git\n", + "%cd Knowledge_Graph_Question_Answering\n", + "!git checkout revamp\n", + "!pip install -q -r requirements.txt" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Install + start Ollama, pull the LLM.\n", + "!apt-get install -y zstd -q\n", + "!curl -fsSL https://ollama.com/install.sh | sh\n", + "import subprocess, time\n", + "subprocess.Popen(['ollama', 'serve'])\n", + "time.sleep(5)\n", + "!ollama pull deepseek-r1:8b" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "from google.colab import userdata\n", + "os.environ['ARANGO_PASS'] = userdata.get('ARANGO_PASS')\n", + "print('ARANGO_PASS set:', bool(os.environ.get('ARANGO_PASS')))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Run all four arms. The chunk corpus is downloaded once and cached, then\n", + "# reused by every arm (identical corpus -> fair comparison).\n", + "for arm in ['plain', 'plain_rr', 'graph', 'graph_concepts']:\n", + " print(f'\\n===== {arm} =====')\n", + " !python scripts/run_benchmark.py --arm {arm} --n 200 --no-ollama-start" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Summary table, paired McNemar tests, and the ablation figure.\n", + "!python scripts/compare.py\n", + "from IPython.display import Image, display, Markdown\n", + "display(Markdown(open('results/summary.md').read()))\n", + "display(Image('results/ablation.png'))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Optional: commit results back to GitHub (set a PAT first).\n", + "# !git config user.email you@example.com && git config user.name you\n", + "# !git add results/ && git commit -m 'Add benchmark results' && git push" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": {"provenance": [], "gpuType": "T4"}, + "kernelspec": {"display_name": "Python 3", "name": "python3"}, + "language_info": {"name": "python"} + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..351d810 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "kgqa" +version = "1.0.0" +description = "Fair GraphRAG vs PlainRAG comparison on PubMedQA" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +pythonpath = ["src", "."] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +line-length = 100 +src = ["src", "scripts", "tests"] +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "UP", "B"] +ignore = ["E501"] # line length handled by formatter; long AQL strings are fine + +[tool.ruff.lint.per-file-ignores] +"scripts/*" = ["E402"] # sys.path insert before imports is intentional diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..1008b1c --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt + +# Testing & linting (CI) +pytest>=8.0 +ruff>=0.4.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d0333e0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,26 @@ +# Core ML / retrieval +sentence-transformers>=2.7.0 +datasets>=2.18.0 +numpy>=1.24 +scikit-learn>=1.3 +scipy>=1.10 + +# Vector index (PlainRAG local fallback) +faiss-cpu>=1.7.4 + +# Knowledge graph +python-arango>=7.9.0 + +# LLM client +requests>=2.31 + +# Plotting / reporting +matplotlib>=3.7 +seaborn>=0.13 +pandas>=2.0 + +# Notebooks / UI (optional at runtime, used by notebooks) +tqdm>=4.66 + +# Config +python-dotenv>=1.0 diff --git a/run_comparison.py b/run_comparison.py deleted file mode 100644 index f08be3f..0000000 --- a/run_comparison.py +++ /dev/null @@ -1,127 +0,0 @@ -""" -Comparison script โ€” loads both result JSON files and prints/plots the comparison. -Run after run_plainrag.py and run_graphrag.py have both completed. -""" - -import os -import sys -import json -import numpy as np -import pandas as pd -import matplotlib.pyplot as plt -import seaborn as sns -from sklearn.metrics import f1_score, confusion_matrix - -ROOT = os.path.dirname(os.path.abspath(__file__)) -RESULTS_DIR = os.path.join(ROOT, 'results') -GRAPHRAG_F = os.path.join(RESULTS_DIR, 'graphrag_results.json') -PLAINRAG_F = os.path.join(RESULTS_DIR, 'plainrag_results.json') -LABELS = ['yes', 'no', 'maybe'] - -for path in [GRAPHRAG_F, PLAINRAG_F]: - if not os.path.exists(path): - print(f'Missing: {path}') - print('Run run_graphrag.py and run_plainrag.py first.') - sys.exit(1) - -with open(GRAPHRAG_F) as f: - gr = json.load(f) -with open(PLAINRAG_F) as f: - pr = json.load(f) - -# โ”€โ”€ Summary table โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -gr_f1 = f1_score(gr['y_true'], gr['y_pred'], labels=LABELS, average='macro', zero_division=0) -pr_f1 = f1_score(pr['y_true'], pr['y_pred'], labels=LABELS, average='macro', zero_division=0) - -summary = pd.DataFrame({ - 'Model': [gr['model'], pr['model']], - 'Accuracy (%)': [round(gr['accuracy'] * 100, 2), round(pr['accuracy'] * 100, 2)], - 'Macro F1 (%)': [round(gr_f1 * 100, 2), round(pr_f1 * 100, 2)], - 'Avg Latency (s)': [round(gr['avg_latency'], 2), round(pr['avg_latency'], 2)], - 'Samples': [gr['samples'], pr['samples']], -}) -print('\n' + '=' * 60) -print(' RESULTS SUMMARY') -print('=' * 60) -print(summary.to_string(index=False)) -print('=' * 60) - -# โ”€โ”€ Accuracy / F1 / Latency bars โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -models = [gr['model'], pr['model']] -accs = [gr['accuracy'] * 100, pr['accuracy'] * 100] -f1s = [gr_f1 * 100, pr_f1 * 100] -lats = [gr['avg_latency'], pr['avg_latency']] -colours = ['#2196F3', '#FF9800'] - -fig, axes = plt.subplots(1, 3, figsize=(15, 5)) - -for ax, values, label in zip(axes, [accs, f1s, lats], ['Accuracy (%)', 'Macro F1 (%)', 'Avg Latency (s)']): - bars = ax.bar(models, values, color=colours, width=0.4, edgecolor='white') - ax.set_title(label) - ax.set_ylabel(label) - if 'Latency' not in label: - ax.set_ylim(0, 100) - for bar, val in zip(bars, values): - suffix = 's' if 'Latency' in label else ('%' if '%' in label else '') - ax.text(bar.get_x() + bar.get_width() / 2, - val * 1.03 if 'Latency' in label else val + 1.5, - f'{val:.1f}{suffix}', ha='center', fontweight='bold') - -plt.suptitle( - f'GraphRAG vs Plain RAG โ€” Head-to-Head (n={gr["samples"]} samples)', - fontsize=13, fontweight='bold', y=1.02, -) -plt.tight_layout() -plt.savefig(os.path.join(RESULTS_DIR, 'comparison_bars.png'), dpi=150, bbox_inches='tight') -plt.show() -print(f'Saved: {os.path.join(RESULTS_DIR, "comparison_bars.png")}') - -# โ”€โ”€ Confusion matrices โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -fig, axes = plt.subplots(1, 2, figsize=(13, 5)) -for ax, res, cmap in zip(axes, [gr, pr], ['Blues', 'Oranges']): - cm = confusion_matrix(res['y_true'], res['y_pred'], labels=LABELS) - sns.heatmap(cm, annot=True, fmt='d', cmap=cmap, - xticklabels=LABELS, yticklabels=LABELS, ax=ax) - ax.set_xlabel('Predicted') - ax.set_ylabel('Actual') - ax.set_title(res['model'] + f' (acc={res["accuracy"]:.2%})') - -plt.suptitle('Confusion Matrices', fontsize=13, fontweight='bold') -plt.tight_layout() -plt.savefig(os.path.join(RESULTS_DIR, 'comparison_confusion.png'), dpi=150, bbox_inches='tight') -plt.show() -print(f'Saved: {os.path.join(RESULTS_DIR, "comparison_confusion.png")}') - -# โ”€โ”€ Per-class F1 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -gr_f1s = f1_score(gr['y_true'], gr['y_pred'], labels=LABELS, average=None, zero_division=0) -pr_f1s = f1_score(pr['y_true'], pr['y_pred'], labels=LABELS, average=None, zero_division=0) - -x, width = np.arange(len(LABELS)), 0.35 -fig, ax = plt.subplots(figsize=(9, 5)) -b1 = ax.bar(x - width / 2, gr_f1s * 100, width, label=gr['model'], color='#2196F3', edgecolor='white') -b2 = ax.bar(x + width / 2, pr_f1s * 100, width, label=pr['model'], color='#FF9800', edgecolor='white') -ax.set_xticks(x); ax.set_xticklabels(LABELS) -ax.set_ylabel('F1 Score (%)'); ax.set_ylim(0, 100) -ax.set_title('Per-class F1 Score'); ax.legend() -for bar in list(b1) + list(b2): - h = bar.get_height() - ax.text(bar.get_x() + bar.get_width() / 2, h + 1, f'{h:.1f}', ha='center', fontsize=9) -plt.tight_layout() -plt.savefig(os.path.join(RESULTS_DIR, 'comparison_f1.png'), dpi=150, bbox_inches='tight') -plt.show() -print(f'Saved: {os.path.join(RESULTS_DIR, "comparison_f1.png")}') - -# โ”€โ”€ Verdict โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -acc_delta = (gr['accuracy'] - pr['accuracy']) * 100 -f1_delta = (gr_f1 - pr_f1) * 100 -lat_delta = gr['avg_latency'] - pr['avg_latency'] -winner = gr['model'] if acc_delta >= 0 else pr['model'] - -print('\n' + '=' * 60) -print(' VERDICT') -print('=' * 60) -print(f' Winner : {winner}') -print(f' Accuracy delta : {acc_delta:+.2f} pp (GraphRAG โˆ’ Plain RAG)') -print(f' Macro F1 delta : {f1_delta:+.2f} pp') -print(f' Latency delta : {lat_delta:+.2f}s') -print('=' * 60) diff --git a/run_graphrag.py b/run_graphrag.py deleted file mode 100644 index 16e091a..0000000 --- a/run_graphrag.py +++ /dev/null @@ -1,242 +0,0 @@ -""" -GraphRAG benchmark runner โ€” standalone script (no Jupyter required). -Runs the same pipeline as GraphRAG.ipynb. - -Set ARANGO_PASS before running: - Windows: $env:ARANGO_PASS = "your_password" - Linux: export ARANGO_PASS=your_password -""" - -import os -import sys -import time -import subprocess - -# Add repo root to path so shared_utils is importable -ROOT = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, ROOT) - -# โ”€โ”€ Start Ollama (idempotent) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -print('[Ollama] Starting server...') -subprocess.Popen( - ['ollama', 'serve'], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, -) -time.sleep(3) - -# โ”€โ”€ Imports โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -import pickle -import requests -import numpy as np -from arango import ArangoClient -from arango.exceptions import ServerConnectionError, ArangoServerError -from datasets import load_dataset -from sentence_transformers import SentenceTransformer, CrossEncoder -from sklearn.metrics.pairwise import cosine_similarity -from tqdm import tqdm - -from shared_utils import ( - EMBEDDING_MODEL, LLM_MODEL, TOP_K_FINAL, - BENCHMARK_SYSTEM_PROMPT, CHAT_SYSTEM_PROMPT, - FuzzyEvaluator, Evaluator, call_ollama, -) - -# โ”€โ”€ Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -ARANGO_HOST = os.environ.get('ARANGO_HOST', 'https://bfc25a0e3c74.arangodb.cloud:8529') -ARANGO_USER = os.environ.get('ARANGO_USER', 'root') -ARANGO_PASS = os.environ.get('ARANGO_PASS', '') -ARANGO_DB = os.environ.get('ARANGO_DB', 'pubmed_graph') - -TOP_K_CANDIDATES = 75 -CROSS_ENCODER = 'cross-encoder/ms-marco-MiniLM-L-6-v2' -VECTOR_CACHE_FILE = os.path.join(ROOT, 'pubmed_vectors_cache.pkl') -RESULTS_DIR = os.path.join(ROOT, 'results') -RESULTS_FILE = os.path.join(RESULTS_DIR, 'graphrag_results.json') -BENCHMARK_N = 100 # must match run_plainrag.py - -os.makedirs(RESULTS_DIR, exist_ok=True) - -if not ARANGO_PASS: - raise EnvironmentError( - 'Set ARANGO_PASS before running:\n' - ' PowerShell : $env:ARANGO_PASS = "your_password"\n' - ' CMD : set ARANGO_PASS=your_password' - ) - -# โ”€โ”€ ArangoDB โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -def connect_arango(host, user, password, db_name, max_retries=5): - client = ArangoClient(hosts=host) - for attempt in range(max_retries): - try: - sys_db = client.db('_system', username=user, password=password) - sys_db.version() - db = client.db(db_name, username=user, password=password) - print('[ArangoDB] Connected.') - return db - except (ServerConnectionError, ArangoServerError) as exc: - wait = (attempt + 1) * 5 - print(f'[ArangoDB] Attempt {attempt + 1} failed. Retrying in {wait}s...') - time.sleep(wait) - raise ConnectionError('Could not connect to ArangoDB.') - - -def load_chunk_vectors(db, collection='Chunks', cache_file=VECTOR_CACHE_FILE): - if os.path.exists(cache_file): - print(f'[Cache] Loading from {cache_file}...') - try: - with open(cache_file, 'rb') as f: - data = pickle.load(f) - ids, texts, embeddings = data['ids'], data['texts'], data['embeddings'] - if len(embeddings) > 0: - print(f'[Cache] Loaded {len(embeddings):,} vectors.') - return ids, texts, np.array(embeddings) - except Exception as exc: - print(f'[Cache] Corrupted ({exc}). Re-downloading...') - - print('[Index] Downloading vectors from ArangoDB (first run only)...') - ids, texts, embeddings = [], [], [] - BATCH, offset = 5000, 0 - - try: - total = list(db.aql.execute(f'RETURN LENGTH({collection})'))[0] - except Exception: - total = 0 - - with tqdm(total=total, desc='Downloading') as pbar: - while True: - aql = f''' - FOR c IN {collection} - FILTER c.embedding != null - LIMIT {offset}, {BATCH} - RETURN {{ id: c._id, text: c.text, emb: c.embedding }} - ''' - try: - batch = list(db.aql.execute(aql, ttl=3600)) - except Exception as exc: - print(f'[Index] Error: {exc}') - if '503' in str(exc): - time.sleep(5) - break - - if not batch: - break - for doc in batch: - ids.append(doc['id']) - texts.append(doc['text']) - embeddings.append(doc['emb']) - - pbar.update(len(batch)) - offset += len(batch) - if len(batch) < BATCH: - break - time.sleep(0.1) - - embeddings_np = np.array(embeddings) - if ids: - with open(cache_file, 'wb') as f: - pickle.dump({'ids': ids, 'texts': texts, 'embeddings': embeddings_np}, f) - print(f'[Cache] Saved {len(ids):,} vectors.') - return ids, texts, embeddings_np - -# โ”€โ”€ GraphRAG โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -class GraphRAG: - def __init__(self, db, chunk_ids, chunk_texts, chunk_embeddings): - self.db = db - self.chunk_ids = chunk_ids - self.chunk_texts = chunk_texts - self.chunk_embeddings = chunk_embeddings - print('[Model] Loading Sentence Transformer...') - self.encoder = SentenceTransformer(EMBEDDING_MODEL) - print('[Model] Loading CrossEncoder...') - self.reranker = CrossEncoder(CROSS_ENCODER) - print('[GraphRAG] Initialised.') - - def retrieve(self, query: str) -> str: - if len(self.chunk_embeddings) == 0: - return 'No context available.' - - query_emb = self.encoder.encode([query]) - sims = cosine_similarity(query_emb, self.chunk_embeddings)[0] - top_idx = np.argsort(sims)[-TOP_K_CANDIDATES:][::-1] - candidates = [(self.chunk_texts[i], self.chunk_ids[i]) for i in top_idx] - - pairs = [[query, text] for text, _ in candidates] - scores = self.reranker.predict(pairs) - best_idx = np.argsort(scores)[::-1][:TOP_K_FINAL] - best_ids = [candidates[i][1] for i in best_idx] - - return self._expand_via_graph(best_ids) - - def _expand_via_graph(self, chunk_ids: list) -> str: - aql = ''' - WITH Papers, Chunks - FOR cid IN @ids - LET chunk = DOCUMENT(cid) - FOR paper IN 1..1 INBOUND chunk HAS_CONTEXT - LET all_chunks = ( - FOR c IN 1..1 OUTBOUND paper HAS_CONTEXT - RETURN c.text - ) - LET full_abstract = CONCAT_SEPARATOR(" ", all_chunks) - RETURN { title: paper.title, abstract: full_abstract } - ''' - try: - rows = list(self.db.aql.execute(aql, bind_vars={'ids': chunk_ids})) - seen = set() - parts = [] - for row in rows: - title = row.get('title', 'Unknown Study') - if title in seen: - continue - seen.add(title) - abstract = row.get('abstract', '') - parts.append('=== STUDY: ' + title + ' ===\n' + abstract) - return '\n\n'.join(parts) if parts else 'No context found.' - except Exception as exc: - print(f'[GraphRAG] Graph expansion failed ({exc}). Using raw chunks.') - return '\n\n'.join( - 'Excerpt: ' + text - for text, _ in candidates[:TOP_K_FINAL] - ) - - def answer_benchmark(self, question: str) -> str: - context = self.retrieve(question) - prompt = 'Context:\n' + context + '\n\nQuestion: ' + question - return call_ollama(prompt, system=BENCHMARK_SYSTEM_PROMPT, temperature=0.0) - - -# โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -if __name__ == '__main__': - db = connect_arango(ARANGO_HOST, ARANGO_USER, ARANGO_PASS, ARANGO_DB) - chunk_ids, chunk_texts, chunk_embeddings = load_chunk_vectors(db) - rag = GraphRAG(db, chunk_ids, chunk_texts, chunk_embeddings) - - dataset = load_dataset('qiaojin/PubMedQA', 'pqa_labeled', split='train') - fuzzy = FuzzyEvaluator() - evaluator = Evaluator('GraphRAG') - - print(f'\n=== GraphRAG Benchmark (n={BENCHMARK_N}) ===') - - for i, item in enumerate(dataset): - if i >= BENCHMARK_N: - break - - question = item['question'] - gt = item['final_decision'] - - t0 = time.time() - raw = rag.answer_benchmark(question) - latency = time.time() - t0 - - pred = fuzzy.extract_answer(raw) - evaluator.record(gt, pred, latency) - - icon = 'v' if pred == gt else 'x' - print(f'[{i+1:3d}] GT={gt:<5} Pred={pred:<5} {icon} ({latency:.1f}s)') - - evaluator.report() - evaluator.save(RESULTS_FILE) diff --git a/run_plainrag.py b/run_plainrag.py deleted file mode 100644 index 8c537fb..0000000 --- a/run_plainrag.py +++ /dev/null @@ -1,162 +0,0 @@ -""" -Plain RAG benchmark runner โ€” standalone script (no Jupyter required). -Runs the same pipeline as Plain_RAG/Plain_RAG.ipynb. -""" - -import os -import sys -import time -import subprocess - -# Add repo root to path so shared_utils is importable -ROOT = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, ROOT) - -# โ”€โ”€ Start Ollama (idempotent โ€” safe to call if already running) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -print('[Ollama] Starting server...') -subprocess.Popen( - ['ollama', 'serve'], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, -) -time.sleep(3) - -# โ”€โ”€ Imports (after Ollama is up) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -import os -import re -import pickle -import requests -import numpy as np -import faiss -import torch -from datasets import load_dataset -from sentence_transformers import SentenceTransformer -from tqdm import tqdm - -from shared_utils import ( - EMBEDDING_MODEL, LLM_MODEL, TOP_K_FINAL, - BENCHMARK_SYSTEM_PROMPT, CHAT_SYSTEM_PROMPT, - FuzzyEvaluator, Evaluator, call_ollama, -) - -# โ”€โ”€ Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -PLAIN_RAG_DIR = os.path.join(ROOT, 'Plain_RAG') -INDEX_FILE = os.path.join(PLAIN_RAG_DIR, 'pubmed_rag_index.bin') -DATA_FILE = os.path.join(PLAIN_RAG_DIR, 'pubmed_rag_data.pkl') -RESULTS_DIR = os.path.join(ROOT, 'results') -RESULTS_FILE = os.path.join(RESULTS_DIR, 'plainrag_results.json') -BENCHMARK_N = 100 # change to run more samples - -os.makedirs(RESULTS_DIR, exist_ok=True) - -# โ”€โ”€ PlainRAG โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -class PlainRAG: - def __init__(self): - self.device = 'cuda' if torch.cuda.is_available() else 'cpu' - print(f'[PlainRAG] Initialising on {self.device}') - self.embedder = SentenceTransformer(EMBEDDING_MODEL, device=self.device) - self.dim = self.embedder.get_sentence_embedding_dimension() - self.index = None - self.documents = [] - self.labeled_data = [] - - def load_data(self): - if os.path.exists(INDEX_FILE) and os.path.exists(DATA_FILE): - print('[Index] Loading from disk...') - self.index = faiss.read_index(INDEX_FILE) - with open(DATA_FILE, 'rb') as f: - saved = pickle.load(f) - self.documents = saved['documents'] - self.labeled_data = saved['labeled_data'] - print(f'[Index] Loaded {len(self.documents):,} documents.') - return - - print('[Data] Building index from scratch (first run โ€” takes ~10 min)...') - ds_labeled = load_dataset('qiaojin/PubMedQA', 'pqa_labeled', split='train') - ds_unlabeled = load_dataset('qiaojin/PubMedQA', 'pqa_unlabeled', split='train') - ds_artificial = load_dataset('qiaojin/PubMedQA', 'pqa_artificial', split='train') - - def process_split(ds, name): - docs = [] - for item in tqdm(ds, desc=f'Processing {name}'): - docs.append({ - 'text': ' '.join(item['context']['contexts']), - 'pubid': item['pubid'], - 'question': item.get('question', ''), - 'final_decision': item.get('final_decision'), - }) - return docs - - self.labeled_data = process_split(ds_labeled, 'labeled') - all_docs = list(self.labeled_data) - all_docs.extend(process_split(ds_unlabeled, 'unlabeled')) - all_docs.extend(process_split(ds_artificial, 'artificial')) - self.documents = all_docs - - print(f'[Embed] Encoding {len(self.documents):,} documents...') - embeddings = self.embedder.encode( - [d['text'] for d in self.documents], - batch_size=128, - show_progress_bar=True, - convert_to_numpy=True, - normalize_embeddings=True, - ) - - print('[Index] Building FAISS IndexFlatIP...') - index_cpu = faiss.IndexFlatIP(self.dim) - index_cpu.add(embeddings) - os.makedirs(PLAIN_RAG_DIR, exist_ok=True) - faiss.write_index(index_cpu, INDEX_FILE) - with open(DATA_FILE, 'wb') as f: - pickle.dump({'documents': self.documents, 'labeled_data': self.labeled_data}, f) - print(f'[Index] Saved to {INDEX_FILE}.') - self.index = index_cpu - - def retrieve(self, query: str) -> str: - vec = self.embedder.encode([query], convert_to_numpy=True, normalize_embeddings=True) - _, indices = self.index.search(vec, TOP_K_FINAL) - retrieved = [self.documents[i] for i in indices[0] if i != -1] - return '\n\n'.join( - 'Abstract ' + str(i + 1) + ': ' + doc['text'] - for i, doc in enumerate(retrieved) - ) - - def answer_benchmark(self, question: str) -> str: - context = self.retrieve(question) - prompt = 'Context:\n' + context + '\n\nQuestion: ' + question - return call_ollama(prompt, system=BENCHMARK_SYSTEM_PROMPT, temperature=0.0) - - -# โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -if __name__ == '__main__': - rag = PlainRAG() - rag.load_data() - - fuzzy = FuzzyEvaluator() - evaluator = Evaluator('Plain RAG') - - print(f'\n=== Plain RAG Benchmark (n={BENCHMARK_N}) ===') - - for i, item in enumerate(rag.labeled_data): - if i >= BENCHMARK_N: - break - - question = item.get('question', '') - gt = item.get('final_decision', '') - if not question or not gt: - continue - - t0 = time.time() - raw = rag.answer_benchmark(question) - latency = time.time() - t0 - - pred = fuzzy.extract_answer(raw) - evaluator.record(gt, pred, latency) - - icon = 'v' if pred == gt else 'x' - print(f'[{i+1:3d}] GT={gt:<5} Pred={pred:<5} {icon} ({latency:.1f}s)') - - evaluator.report() - evaluator.save(RESULTS_FILE) diff --git a/scripts/compare.py b/scripts/compare.py new file mode 100644 index 0000000..b48bafe --- /dev/null +++ b/scripts/compare.py @@ -0,0 +1,143 @@ +"""Aggregate arm results: summary table, McNemar tests, and figures. + + python scripts/compare.py + +Reads results/{arm}_results.json for whichever arms are present and writes +figures + a markdown snippet to results/. The McNemar tests are paired on pubid, +so they only run for arms evaluated on the same seeded sample. +""" + +from __future__ import annotations + +import json +import os +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "src")) + +from kgqa.evaluation import mcnemar_test # noqa: E402 + +RESULTS_DIR = os.path.join(ROOT, "results") +ARM_ORDER = ["plain", "plain_rr", "graph", "graph_concepts"] +# Adjacent-arm contrasts that isolate each component's contribution. +CONTRASTS = [ + ("plain", "plain_rr", "reranker effect"), + ("plain_rr", "graph", "parent-expansion effect"), + ("graph", "graph_concepts", "concept-hop effect"), +] + + +def load_results(): + out = {} + for arm in ARM_ORDER: + path = os.path.join(RESULTS_DIR, f"{arm}_results.json") + if os.path.exists(path): + with open(path) as f: + out[arm] = json.load(f) + return out + + +def aligned(a, b): + """Align two arms' predictions on shared pubids (same seed -> same order).""" + ids_a = a.get("ids") or list(range(len(a["y_pred"]))) + ids_b = b.get("ids") or list(range(len(b["y_pred"]))) + idx_b = {sid: i for i, sid in enumerate(ids_b)} + gt, pa, pb = [], [], [] + for i, sid in enumerate(ids_a): + j = idx_b.get(sid) + if j is None: + continue + gt.append(a["y_true"][i]) + pa.append(a["y_pred"][i]) + pb.append(b["y_pred"][j]) + return gt, pa, pb + + +def main(): + results = load_results() + if not results: + print(f"No results found in {RESULTS_DIR}. Run scripts/run_benchmark.py first.") + sys.exit(1) + + lines = ["| Arm | Accuracy | Macro F1 | Avg latency (s) | n |", + "| --- | --- | --- | --- | --- |"] + print("\n" + "=" * 64) + print(" RESULTS SUMMARY") + print("=" * 64) + for arm in ARM_ORDER: + if arm not in results: + continue + r = results[arm] + acc, f1 = r["accuracy"] * 100, r.get("macro_f1", 0) * 100 + lat, n = r["avg_latency"], r["samples"] + print(f" {arm:<16} acc={acc:6.2f}% f1={f1:6.2f}% lat={lat:5.1f}s n={n}") + lines.append(f"| {arm} | {acc:.2f}% | {f1:.2f}% | {lat:.1f} | {n} |") + + print("\n" + "=" * 64) + print(" PAIRED McNEMAR TESTS (adjacent ablation contrasts)") + print("=" * 64) + lines += ["", "### Significance (paired McNemar)", "", + "| Contrast | ฮ”acc (pp) | gains | losses | p | sig? |", + "| --- | --- | --- | --- | --- | --- |"] + for a_name, b_name, desc in CONTRASTS: + if a_name not in results or b_name not in results: + continue + gt, pa, pb = aligned(results[a_name], results[b_name]) + if not gt: + continue + test = mcnemar_test(gt, pa, pb) + acc_a = sum(p == g for p, g in zip(pa, gt, strict=False)) / len(gt) + acc_b = sum(p == g for p, g in zip(pb, gt, strict=False)) / len(gt) + d = (acc_b - acc_a) * 100 + sig = "yes" if test["significant_at_0.05"] else "no" + print(f" {a_name} -> {b_name} ({desc})") + print(f" ฮ”acc={d:+.2f}pp gains={test['b_gains']} losses={test['c_losses']}" + f" p={test['p_value']:.4f} sig={sig}") + lines.append(f"| {a_name} โ†’ {b_name} ({desc}) | {d:+.2f} | {test['b_gains']} " + f"| {test['c_losses']} | {test['p_value']:.4f} | {sig} |") + + md_path = os.path.join(RESULTS_DIR, "summary.md") + with open(md_path, "w") as f: + f.write("\n".join(lines) + "\n") + print(f"\nWrote {md_path}") + + _plot(results) + + +def _plot(results): + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + except Exception as exc: # pragma: no cover + print(f"(skipping figures: {exc})") + return + + arms = [a for a in ARM_ORDER if a in results] + accs = [results[a]["accuracy"] * 100 for a in arms] + f1s = [results[a].get("macro_f1", 0) * 100 for a in arms] + + fig, ax = plt.subplots(figsize=(9, 5)) + import numpy as np + x = np.arange(len(arms)) + w = 0.38 + ax.bar(x - w / 2, accs, w, label="Accuracy", color="#2196F3") + ax.bar(x + w / 2, f1s, w, label="Macro F1", color="#FF9800") + ax.set_xticks(x) + ax.set_xticklabels(arms, rotation=15) + ax.set_ylabel("%") + ax.set_ylim(0, 100) + ax.set_title("4-arm ablation โ€” PubMedQA") + ax.legend() + for i, (a, f) in enumerate(zip(accs, f1s, strict=False)): + ax.text(i - w / 2, a + 1, f"{a:.1f}", ha="center", fontsize=8) + ax.text(i + w / 2, f + 1, f"{f:.1f}", ha="center", fontsize=8) + fig.tight_layout() + out = os.path.join(RESULTS_DIR, "ablation.png") + fig.savefig(out, dpi=150, bbox_inches="tight") + print(f"Wrote {out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/ingest.py b/scripts/ingest.py new file mode 100644 index 0000000..311e151 --- /dev/null +++ b/scripts/ingest.py @@ -0,0 +1,139 @@ +"""Build the ArangoDB knowledge graph from PubMedQA โ€” leakage-free schema. + +Differences from the original ingestion (the fairness fixes): + * Papers store NO question-derived title and NO final_decision, so the + benchmark question/answer can never leak into a retrieved context. + * Chunks carry an explicit ``paper_key`` for fast, unambiguous corpus loading. + +Run ONCE before benchmarking: + export ARANGO_PASS=... # or set in PowerShell / Colab Secrets + python scripts/ingest.py +""" + +from __future__ import annotations + +import argparse +import os +import sys +import time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "src")) + +from arango import ArangoClient # noqa: E402 +from datasets import load_dataset # noqa: E402 +from sentence_transformers import SentenceTransformer # noqa: E402 +from tqdm import tqdm # noqa: E402 + +from kgqa.config import ( # noqa: E402 + DATASET_NAME, + EDGE_COLLECTIONS, + EMBEDDING_MODEL, + LABELED_CONFIG, + NODE_COLLECTIONS, + UNLABELED_CONFIG, + ArangoConfig, +) + + +def setup_schema(db): + for col in NODE_COLLECTIONS: + if not db.has_collection(col): + db.create_collection(col) + print(f" created node collection: {col}") + for col in EDGE_COLLECTIONS: + if not db.has_collection(col): + db.create_collection(col, edge=True) + print(f" created edge collection: {col}") + + +def ingest_split(db, dataset, model, on_duplicate_paper="ignore", batch_size=50): + papers, chunks, concepts, has_ctx, mentions = [], [], [], [], [] + count = 0 + + def flush(): + if papers: + db.collection("Papers").import_bulk(papers, on_duplicate=on_duplicate_paper) + if concepts: + db.collection("Concepts").import_bulk(concepts, on_duplicate="ignore") + if chunks: + db.collection("Chunks").import_bulk(chunks, on_duplicate="ignore") + if has_ctx: + db.collection("HAS_CONTEXT").import_bulk(has_ctx, on_duplicate="ignore") + if mentions: + db.collection("MENTIONS").import_bulk(mentions, on_duplicate="ignore") + for buf in (papers, chunks, concepts, has_ctx, mentions): + buf.clear() + + for row in tqdm(dataset): + paper_key = str(row["pubid"]) + # Leakage-free Paper node: no title, no final_decision. + papers.append({"_key": paper_key}) + + for mesh in row.get("context", {}).get("meshes", []): + mesh_key = "".join(c for c in mesh if c.isalnum()) + if not mesh_key: + continue + concepts.append({"_key": mesh_key, "name": mesh}) + mentions.append({"_from": f"Papers/{paper_key}", "_to": f"Concepts/{mesh_key}"}) + + ctx_texts = row.get("context", {}).get("contexts", []) + ctx_labels = row.get("context", {}).get("labels", []) + if ctx_texts: + embeddings = model.encode(ctx_texts) + for idx, (text, emb) in enumerate(zip(ctx_texts, embeddings, strict=False)): + chunk_key = f"{paper_key}_{idx}" + chunks.append({ + "_key": chunk_key, + "paper_key": paper_key, + "text": text, + "label": ctx_labels[idx] if idx < len(ctx_labels) else "context", + "embedding": emb.tolist(), + }) + has_ctx.append({"_from": f"Papers/{paper_key}", "_to": f"Chunks/{chunk_key}"}) + + count += 1 + if count % batch_size == 0: + flush() + flush() + return count + + +def main(): + parser = argparse.ArgumentParser(description="Ingest PubMedQA into ArangoDB.") + parser.add_argument("--no-unlabeled", action="store_true", + help="Ingest only the labeled split (faster, for testing).") + args = parser.parse_args() + + cfg = ArangoConfig() + cfg.require_password() + client = ArangoClient(hosts=cfg.host) + sys_db = client.db("_system", username=cfg.user, password=cfg.password) + if not sys_db.has_database(cfg.db_name): + sys_db.create_database(cfg.db_name) + print(f"created database: {cfg.db_name}") + db = client.db(cfg.db_name, username=cfg.user, password=cfg.password) + + setup_schema(db) + model = SentenceTransformer(EMBEDDING_MODEL) + + if not args.no_unlabeled: + print("Ingesting pqa_unlabeled...") + ds = load_dataset(DATASET_NAME, UNLABELED_CONFIG, split="train") + t0 = time.time() + n = ingest_split(db, ds, model, on_duplicate_paper="ignore") + print(f" {n:,} papers in {time.time() - t0:.1f}s") + + print("Ingesting pqa_labeled...") + ds = load_dataset(DATASET_NAME, LABELED_CONFIG, split="train") + t0 = time.time() + n = ingest_split(db, ds, model, on_duplicate_paper="update") + print(f" {n:,} papers in {time.time() - t0:.1f}s") + + print("\nCollection counts:") + for col in (*NODE_COLLECTIONS, *EDGE_COLLECTIONS): + print(f" {col:<15}: {db.collection(col).count():>8,}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_benchmark.py b/scripts/run_benchmark.py new file mode 100644 index 0000000..8dade58 --- /dev/null +++ b/scripts/run_benchmark.py @@ -0,0 +1,106 @@ +"""Run one arm of the GraphRAG vs PlainRAG ablation on PubMedQA. + + python scripts/run_benchmark.py --arm plain_rr --n 200 + +Arms: + plain vector top-k chunks (baseline) + plain_rr + cross-encoder rerank + graph + parent-paper expansion (full abstracts) + graph_concepts + MeSH concept-hop expansion + +All arms share one ArangoDB-backed chunk corpus (cached locally), the same +encoder, reranker, prompt, LLM, seed and sample โ€” so results are comparable and +the only moving part is the retrieval strategy named by --arm. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "src")) + +ARMS = ("plain", "plain_rr", "graph", "graph_concepts") + + +def build_retriever(arm, store, encoder, reranker, db): + from kgqa.retrieval import GraphRetriever, PlainRetriever + + if arm == "plain": + return PlainRetriever(store, encoder, reranker=None) + if arm == "plain_rr": + return PlainRetriever(store, encoder, reranker=reranker) + if arm == "graph": + return GraphRetriever(store, encoder, db, reranker=reranker, use_concepts=False) + if arm == "graph_concepts": + return GraphRetriever(store, encoder, db, reranker=reranker, use_concepts=True) + raise ValueError(f"unknown arm: {arm}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--arm", required=True, choices=ARMS) + parser.add_argument("--n", type=int, default=None, help="sample size (default: config BENCHMARK_N)") + parser.add_argument("--seed", type=int, default=None, help="random seed (default: config RANDOM_SEED)") + parser.add_argument("--output", default=None, help="results JSON path") + parser.add_argument("--no-ollama-start", action="store_true", + help="don't auto-start the Ollama server") + args = parser.parse_args() + + if not args.no_ollama_start: + print("[Ollama] Starting server (idempotent)...") + try: + subprocess.Popen(["ollama", "serve"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(3) + except FileNotFoundError: + print("[Ollama] 'ollama' not found on PATH; assuming it is already running.") + + from kgqa.config import BENCHMARK_N, RANDOM_SEED, ArangoConfig + from kgqa.data import load_benchmark_samples + from kgqa.evaluation import Evaluator, FuzzyEvaluator + from kgqa.models import connect_arango, load_encoder, load_reranker + from kgqa.retrieval import ChunkStore + + n = args.n or BENCHMARK_N + seed = args.seed if args.seed is not None else RANDOM_SEED + results_dir = os.path.join(ROOT, "results") + os.makedirs(results_dir, exist_ok=True) + out_path = args.output or os.path.join(results_dir, f"{args.arm}_results.json") + cache_file = os.path.join(ROOT, "pubmed_vectors_cache.pkl") + + db = connect_arango(ArangoConfig()) + print("[Corpus] Loading chunk store from ArangoDB (cached after first run)...") + store = ChunkStore.from_arango(db, cache_file=cache_file) + print(f"[Corpus] {len(store):,} chunks loaded.") + + encoder = load_encoder() + needs_rerank = args.arm != "plain" + reranker = load_reranker() if needs_rerank else None + + retriever = build_retriever(args.arm, store, encoder, reranker, db) + samples = load_benchmark_samples(n=n, seed=seed) + + fuzzy = FuzzyEvaluator() + evaluator = Evaluator(args.arm) + print(f"\n=== Benchmark: {args.arm} (n={len(samples)}, seed={seed}) ===") + for i, s in enumerate(samples): + t0 = time.time() + raw = retriever.answer_benchmark(s.question) + latency = time.time() - t0 + pred = fuzzy.extract_answer(raw) + evaluator.record(s.final_decision, pred, latency, sample_id=s.pubid) + icon = "v" if pred == s.final_decision.lower().strip() else "x" + print(f"[{i + 1:3d}] GT={s.final_decision:<5} Pred={pred:<5} {icon} ({latency:.1f}s)") + + evaluator.report() + evaluator.save(out_path) + + +if __name__ == "__main__": + main() diff --git a/shared_utils.py b/shared_utils.py deleted file mode 100644 index 8007751..0000000 --- a/shared_utils.py +++ /dev/null @@ -1,153 +0,0 @@ -""" -Shared utilities for the Knowledge Graph QA project. - -GraphRAG.ipynb and Plain_RAG/Plain_RAG.ipynb both import from this module -to guarantee identical embedding models, LLM, prompts, answer extraction, -and evaluation โ€” so the only variables between them are the retrieval strategy -and context assembly method. -""" - -import re -import json -import time -import requests -import numpy as np -import pandas as pd -import matplotlib.pyplot as plt -import seaborn as sns -from sklearn.metrics import accuracy_score, classification_report, confusion_matrix - -# โ”€โ”€ Model identifiers (both notebooks must use these exact values) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -EMBEDDING_MODEL = 'sentence-transformers/all-MiniLM-L6-v2' # 384-dim -LLM_MODEL = 'deepseek-r1:8b' -OLLAMA_API = 'http://localhost:11434/api/chat' -TOP_K_FINAL = 3 # documents fed to the LLM in both pipelines - -# โ”€โ”€ Prompts (word-for-word identical in both pipelines) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -BENCHMARK_SYSTEM_PROMPT = ( - 'You are a PubMedQA annotator. Classify the answer as yes, no, or maybe.\n\n' - 'Guidelines:\n' - '- YES : the study finds a positive outcome, correlation, or association,\n' - ' even if further research is recommended.\n' - '- NO : the study finds no significant difference or a negative result.\n' - '- MAYBE: only if the abstract explicitly states inconclusive results\n' - ' with no supporting data.\n\n' - 'End your response with exactly: Final Answer: [yes/no/maybe]' -) - -CHAT_SYSTEM_PROMPT = ( - 'You are a helpful medical AI assistant. ' - 'Use the provided research abstracts to answer the user question. ' - 'Cite specific study titles when making claims. ' - 'If studies conflict, explain the conflict. ' - 'If the context is insufficient, say so and give your best assessment.' -) - - -# โ”€โ”€ Answer extraction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -class FuzzyEvaluator: - """Extracts and normalises yes/no/maybe from verbose model output.""" - - def extract_answer(self, text: str) -> str: - clean = re.sub(r'.*?', '', text, flags=re.DOTALL).lower() - match = re.search(r'final answer\s*:\s*(yes|no|maybe)', clean) - if match: - return match.group(1) - matches = re.findall(r'\b(yes|no|maybe)\b', clean) - return matches[-1] if matches else 'maybe' - - -# โ”€โ”€ Evaluation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -class Evaluator: - """Records predictions and generates a full evaluation report.""" - - def __init__(self, model_name: str): - self.model_name = model_name - self.y_true: list = [] - self.y_pred: list = [] - self.latencies: list = [] - - def record(self, ground_truth: str, prediction: str, latency: float = 0.0): - pred = prediction.lower().strip() - if pred not in ('yes', 'no', 'maybe'): - pred = 'maybe' - self.y_true.append(ground_truth.lower().strip()) - self.y_pred.append(pred) - self.latencies.append(latency) - - def report(self) -> dict: - if not self.y_true: - print('No data recorded.') - return {} - - labels = ['yes', 'no', 'maybe'] - acc = accuracy_score(self.y_true, self.y_pred) - total_t = sum(self.latencies) - avg_t = total_t / len(self.latencies) if self.latencies else 0.0 - - print(f"\n{'=' * 52}") - print(f" {self.model_name} โ€” Evaluation Report") - print(f"{'=' * 52}") - print(f" Samples : {len(self.y_true)}") - print(f" Accuracy : {acc:.2%}") - print(f" Total time : {total_t:.1f}s | Avg/query : {avg_t:.1f}s") - print(f"{'-' * 52}") - print(classification_report(self.y_true, self.y_pred, labels=labels, zero_division=0)) - - cm = confusion_matrix(self.y_true, self.y_pred, labels=labels) - fig, ax = plt.subplots(figsize=(6, 5)) - sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', - xticklabels=labels, yticklabels=labels, ax=ax) - ax.set_xlabel('Predicted') - ax.set_ylabel('Actual') - ax.set_title(f'Confusion Matrix โ€” {self.model_name}') - plt.tight_layout() - plt.show() - - return { - 'model': self.model_name, - 'accuracy': acc, - 'samples': len(self.y_true), - 'total_time': total_t, - 'avg_latency': avg_t, - 'y_true': self.y_true, - 'y_pred': self.y_pred, - } - - def save(self, path: str): - data = { - 'model': self.model_name, - 'accuracy': accuracy_score(self.y_true, self.y_pred) if self.y_true else 0, - 'samples': len(self.y_true), - 'total_time': sum(self.latencies), - 'avg_latency': sum(self.latencies) / len(self.latencies) if self.latencies else 0, - 'y_true': self.y_true, - 'y_pred': self.y_pred, - } - with open(path, 'w') as f: - json.dump(data, f, indent=2) - print(f'Results saved to {path}') - - -# โ”€โ”€ LLM interface โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -def call_ollama(prompt: str, system: str = '', - temperature: float = 0.0, - model: str = LLM_MODEL) -> str: - """Single synchronous call to the local Ollama API.""" - messages = [] - if system: - messages.append({'role': 'system', 'content': system}) - messages.append({'role': 'user', 'content': prompt}) - - payload = { - 'model': model, - 'messages': messages, - 'stream': False, - 'options': {'temperature': temperature, 'num_ctx': 4096}, - } - resp = requests.post(OLLAMA_API, json=payload, timeout=300) - resp.raise_for_status() - return resp.json()['message']['content'] diff --git a/src/kgqa/__init__.py b/src/kgqa/__init__.py new file mode 100644 index 0000000..27cf1c6 --- /dev/null +++ b/src/kgqa/__init__.py @@ -0,0 +1,14 @@ +"""Knowledge Graph Question Answering โ€” fair GraphRAG vs PlainRAG comparison. + +A 4-arm ablation on PubMedQA that isolates exactly what a knowledge graph +contributes to retrieval-augmented QA, holding every other layer constant +(corpus, chunking, embedder, reranker, prompt, LLM, top-k). + +Arms: + plain vector search -> top-k chunks (baseline) + plain_rr vector search -> cross-encoder rerank -> top-k chunks + graph plain_rr -> parent-paper expansion (full abstracts) + graph_concepts graph -> MeSH concept-hop expansion (related papers) +""" + +__version__ = "1.0.0" diff --git a/src/kgqa/config.py b/src/kgqa/config.py new file mode 100644 index 0000000..4b15232 --- /dev/null +++ b/src/kgqa/config.py @@ -0,0 +1,69 @@ +"""Central configuration โ€” the single source of truth for every constant. + +Every arm of the comparison reads from here, so the *only* differences between +PlainRAG and GraphRAG are the retrieval strategy and context assembly. Anything +that could confound the comparison (embedder, reranker, prompt, LLM, top-k, +sample size, seed) lives in this file and nowhere else. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field + +try: # optional: load a local .env if python-dotenv is installed + from dotenv import load_dotenv + + load_dotenv() +except Exception: # pragma: no cover - dotenv is optional + pass + + +# โ”€โ”€ Shared models (identical across all arms) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2" # 384-dim +CROSS_ENCODER = "cross-encoder/ms-marco-MiniLM-L-6-v2" +LLM_MODEL = os.environ.get("LLM_MODEL", "deepseek-r1:8b") + +# โ”€โ”€ Retrieval hyper-parameters (identical across all arms) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +TOP_K_FINAL = 3 # documents handed to the LLM +TOP_K_CANDIDATES = 75 # wide pool fed to the reranker (rerank arms only) +CONCEPT_HOP_PAPERS = 3 # extra related papers pulled in by the concept arm + +# โ”€โ”€ Benchmark protocol (identical across all arms) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +BENCHMARK_N = int(os.environ.get("BENCHMARK_N", "200")) +RANDOM_SEED = int(os.environ.get("RANDOM_SEED", "42")) +DATASET_NAME = "qiaojin/PubMedQA" +LABELED_CONFIG = "pqa_labeled" +UNLABELED_CONFIG = "pqa_unlabeled" + +# โ”€โ”€ LLM serving โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +OLLAMA_API = os.environ.get("OLLAMA_API", "http://localhost:11434/api/chat") +LLM_TEMPERATURE = 0.0 # deterministic for benchmarking +LLM_NUM_CTX = 4096 +LLM_TIMEOUT = 300 + +# โ”€โ”€ Graph schema (must match scripts/ingest.py) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +NODE_COLLECTIONS = ("Papers", "Chunks", "Concepts") +EDGE_COLLECTIONS = ("HAS_CONTEXT", "MENTIONS") +HAS_CONTEXT = "HAS_CONTEXT" # Paper -> Chunk +MENTIONS = "MENTIONS" # Paper -> Concept + + +@dataclass +class ArangoConfig: + """ArangoDB Oasis connection settings, read from the environment.""" + + host: str = field(default_factory=lambda: os.environ.get( + "ARANGO_HOST", "https://localhost:8529")) + user: str = field(default_factory=lambda: os.environ.get("ARANGO_USER", "root")) + password: str = field(default_factory=lambda: os.environ.get("ARANGO_PASS", "")) + db_name: str = field(default_factory=lambda: os.environ.get("ARANGO_DB", "pubmed_graph")) + + def require_password(self) -> None: + if not self.password: + raise OSError( + "ARANGO_PASS is not set. Set it before connecting:\n" + ' PowerShell : $env:ARANGO_PASS = "your_password"\n' + " bash : export ARANGO_PASS=your_password\n" + " Colab : add ARANGO_PASS in the Secrets panel" + ) diff --git a/src/kgqa/data.py b/src/kgqa/data.py new file mode 100644 index 0000000..d4584e8 --- /dev/null +++ b/src/kgqa/data.py @@ -0,0 +1,77 @@ +"""Dataset loading, seeded sampling, and chunk-corpus construction. + +The chunk corpus is built the same way the graph is ingested (per-section +chunks from the labeled + unlabeled splits), so every arm retrieves over an +identical pool of documents. +""" + +from __future__ import annotations + +import random +from dataclasses import dataclass + +from .config import ( + BENCHMARK_N, + DATASET_NAME, + LABELED_CONFIG, + RANDOM_SEED, + UNLABELED_CONFIG, +) + + +@dataclass +class BenchmarkSample: + pubid: str + question: str + final_decision: str + + +def load_benchmark_samples(n: int = BENCHMARK_N, seed: int = RANDOM_SEED) -> list[BenchmarkSample]: + """Return a deterministic random sample of labeled PubMedQA questions. + + Uses a seeded shuffle so the same questions are evaluated across every arm + and across re-runs โ€” a prerequisite for the paired McNemar test. + """ + from datasets import load_dataset + + ds = load_dataset(DATASET_NAME, LABELED_CONFIG, split="train") + indices = list(range(len(ds))) + random.Random(seed).shuffle(indices) + + samples: list[BenchmarkSample] = [] + for idx in indices: + item = ds[idx] + decision = item.get("final_decision") + if not item.get("question") or not decision: + continue + samples.append(BenchmarkSample( + pubid=str(item["pubid"]), + question=item["question"], + final_decision=decision, + )) + if len(samples) >= n: + break + return samples + + +def iter_chunks(include_unlabeled: bool = True): + """Yield ``(paper_key, chunk_index, text)`` for every abstract section. + + This is the canonical chunking used both at ingestion time and when + building the in-memory PlainRAG corpus, guaranteeing an identical document + pool across arms. + """ + from datasets import load_dataset + + configs = [LABELED_CONFIG] + if include_unlabeled: + configs.append(UNLABELED_CONFIG) + + for config in configs: + ds = load_dataset(DATASET_NAME, config, split="train") + for item in ds: + paper_key = str(item["pubid"]) + contexts = item.get("context", {}).get("contexts", []) + for idx, text in enumerate(contexts): + if text and text.strip(): + yield paper_key, idx, text diff --git a/src/kgqa/evaluation.py b/src/kgqa/evaluation.py new file mode 100644 index 0000000..d53a79b --- /dev/null +++ b/src/kgqa/evaluation.py @@ -0,0 +1,131 @@ +"""Answer extraction, metrics, and significance testing. + +Kept free of any plotting import at module load so it is importable in headless +CI. Figure generation lives in ``scripts/compare.py``. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field + +from sklearn.metrics import accuracy_score, classification_report, f1_score + +LABELS = ("yes", "no", "maybe") + + +class FuzzyEvaluator: + """Extracts a normalised yes/no/maybe from verbose model output.""" + + def extract_answer(self, text: str) -> str: + clean = re.sub(r".*?", "", text, flags=re.DOTALL).lower() + match = re.search(r"final answer\s*:\s*(yes|no|maybe)", clean) + if match: + return match.group(1) + matches = re.findall(r"\b(yes|no|maybe)\b", clean) + return matches[-1] if matches else "maybe" + + +@dataclass +class Evaluator: + """Accumulates predictions and computes plot-free metrics. + + ``ids`` records the dataset pubid of each sample so a paired significance + test (McNemar) can be run across arms on exactly the same questions. + """ + + model_name: str + y_true: list = field(default_factory=list) + y_pred: list = field(default_factory=list) + latencies: list = field(default_factory=list) + ids: list = field(default_factory=list) + + def record(self, ground_truth: str, prediction: str, + latency: float = 0.0, sample_id: str | None = None) -> None: + pred = prediction.lower().strip() + if pred not in LABELS: + pred = "maybe" + self.y_true.append(ground_truth.lower().strip()) + self.y_pred.append(pred) + self.latencies.append(latency) + self.ids.append(sample_id) + + # โ”€โ”€ metrics โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def accuracy(self) -> float: + return accuracy_score(self.y_true, self.y_pred) if self.y_true else 0.0 + + def macro_f1(self) -> float: + if not self.y_true: + return 0.0 + return f1_score(self.y_true, self.y_pred, labels=list(LABELS), + average="macro", zero_division=0) + + def avg_latency(self) -> float: + return sum(self.latencies) / len(self.latencies) if self.latencies else 0.0 + + def summary(self) -> dict: + return { + "model": self.model_name, + "accuracy": self.accuracy(), + "macro_f1": self.macro_f1(), + "samples": len(self.y_true), + "total_time": sum(self.latencies), + "avg_latency": self.avg_latency(), + "y_true": self.y_true, + "y_pred": self.y_pred, + "ids": self.ids, + } + + def report(self) -> dict: + if not self.y_true: + print("No data recorded.") + return {} + print(f"\n{'=' * 52}") + print(f" {self.model_name} โ€” Evaluation Report") + print(f"{'=' * 52}") + print(f" Samples : {len(self.y_true)}") + print(f" Accuracy : {self.accuracy():.2%}") + print(f" Macro F1 : {self.macro_f1():.2%}") + print(f" Avg/query : {self.avg_latency():.1f}s") + print(f"{'-' * 52}") + print(classification_report(self.y_true, self.y_pred, + labels=list(LABELS), zero_division=0)) + return self.summary() + + def save(self, path: str) -> None: + with open(path, "w") as f: + json.dump(self.summary(), f, indent=2) + print(f"Results saved to {path}") + + +def mcnemar_test(y_true: list, pred_a: list, pred_b: list) -> dict: + """Paired McNemar test: is arm B's accuracy change over arm A significant? + + Compares the two arms only on the samples where exactly one is correct + (the discordant pairs). Uses the exact binomial test, which is valid for + the small discordant counts typical of n~200 benchmarks. + """ + from scipy.stats import binomtest + + if not (len(y_true) == len(pred_a) == len(pred_b)): + raise ValueError("y_true, pred_a, pred_b must be the same length") + + # b: A wrong, B right (B's gains). c: A right, B wrong (B's losses). + b = c = 0 + for gt, a, bb in zip(y_true, pred_a, pred_b, strict=False): + a_ok, b_ok = (a == gt), (bb == gt) + if a_ok and not b_ok: + c += 1 + elif b_ok and not a_ok: + b += 1 + + n = b + c + p_value = float(binomtest(b, n, 0.5).pvalue) if n > 0 else 1.0 + return { + "b_gains": b, # B right, A wrong + "c_losses": c, # A right, B wrong + "discordant": n, + "p_value": p_value, + "significant_at_0.05": bool(p_value < 0.05), + } diff --git a/src/kgqa/llm.py b/src/kgqa/llm.py new file mode 100644 index 0000000..f2b03db --- /dev/null +++ b/src/kgqa/llm.py @@ -0,0 +1,31 @@ +"""Thin Ollama client โ€” the single LLM entry point shared by all arms.""" + +from __future__ import annotations + +import requests + +from .config import LLM_MODEL, LLM_NUM_CTX, LLM_TEMPERATURE, LLM_TIMEOUT, OLLAMA_API + + +def call_ollama( + prompt: str, + system: str = "", + temperature: float = LLM_TEMPERATURE, + model: str = LLM_MODEL, + api_url: str = OLLAMA_API, +) -> str: + """Single synchronous chat completion against a local Ollama server.""" + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + payload = { + "model": model, + "messages": messages, + "stream": False, + "options": {"temperature": temperature, "num_ctx": LLM_NUM_CTX}, + } + resp = requests.post(api_url, json=payload, timeout=LLM_TIMEOUT) + resp.raise_for_status() + return resp.json()["message"]["content"] diff --git a/src/kgqa/models.py b/src/kgqa/models.py new file mode 100644 index 0000000..6af8a47 --- /dev/null +++ b/src/kgqa/models.py @@ -0,0 +1,44 @@ +"""Lazy loaders for the shared embedder and reranker. + +Kept here so every script and notebook instantiates the *same* models the same +way. Imports are local so the package can be imported without the heavy ML deps +installed (e.g. in unit tests that inject fakes).""" + +from __future__ import annotations + +from .config import CROSS_ENCODER, EMBEDDING_MODEL + + +def load_encoder(model_name: str = EMBEDDING_MODEL, device: str | None = None): + from sentence_transformers import SentenceTransformer + + return SentenceTransformer(model_name, device=device) + + +def load_reranker(model_name: str = CROSS_ENCODER, device: str | None = None): + from sentence_transformers import CrossEncoder + + return CrossEncoder(model_name, device=device) + + +def connect_arango(cfg, max_retries: int = 5): + """Connect to ArangoDB Oasis with retries. ``cfg`` is an ArangoConfig.""" + import time + + from arango import ArangoClient + from arango.exceptions import ArangoServerError, ServerConnectionError + + cfg.require_password() + client = ArangoClient(hosts=cfg.host) + for attempt in range(max_retries): + try: + sys_db = client.db("_system", username=cfg.user, password=cfg.password) + sys_db.version() + db = client.db(cfg.db_name, username=cfg.user, password=cfg.password) + print("[ArangoDB] Connected.") + return db + except (ServerConnectionError, ArangoServerError): + wait = (attempt + 1) * 5 + print(f"[ArangoDB] Attempt {attempt + 1} failed. Retrying in {wait}s...") + time.sleep(wait) + raise ConnectionError("Could not connect to ArangoDB.") diff --git a/src/kgqa/prompts.py b/src/kgqa/prompts.py new file mode 100644 index 0000000..ca67313 --- /dev/null +++ b/src/kgqa/prompts.py @@ -0,0 +1,28 @@ +"""Prompts โ€” word-for-word identical across every arm. + +The benchmark prompt classifies a PubMedQA question as yes/no/maybe. It is the +same string for PlainRAG and GraphRAG; only the retrieved ``context`` differs. +""" + +BENCHMARK_SYSTEM_PROMPT = ( + "You are a PubMedQA annotator. Classify the answer as yes, no, or maybe.\n\n" + "Guidelines:\n" + "- YES : the study finds a positive outcome, correlation, or association,\n" + " even if further research is recommended.\n" + "- NO : the study finds no significant difference or a negative result.\n" + "- MAYBE: only if the abstract explicitly states inconclusive results\n" + " with no supporting data.\n\n" + "End your response with exactly: Final Answer: [yes/no/maybe]" +) + +CHAT_SYSTEM_PROMPT = ( + "You are a helpful medical AI assistant. " + "Use the provided research abstracts to answer the user question. " + "If studies conflict, explain the conflict. " + "If the context is insufficient, say so and give your best assessment." +) + + +def build_prompt(context: str, question: str) -> str: + """Assemble the user-turn prompt โ€” identical structure for every arm.""" + return f"Context:\n{context}\n\nQuestion: {question}" diff --git a/src/kgqa/retrieval/__init__.py b/src/kgqa/retrieval/__init__.py new file mode 100644 index 0000000..b38798a --- /dev/null +++ b/src/kgqa/retrieval/__init__.py @@ -0,0 +1,13 @@ +"""Retrieval arms for the GraphRAG vs PlainRAG ablation.""" + +from .base import BaseRetriever, Candidate, ChunkStore +from .graph import GraphRetriever +from .plain import PlainRetriever + +__all__ = [ + "BaseRetriever", + "ChunkStore", + "Candidate", + "PlainRetriever", + "GraphRetriever", +] diff --git a/src/kgqa/retrieval/base.py b/src/kgqa/retrieval/base.py new file mode 100644 index 0000000..8a67a5d --- /dev/null +++ b/src/kgqa/retrieval/base.py @@ -0,0 +1,168 @@ +"""Shared retrieval scaffolding. + +``ChunkStore`` is the single document pool every arm searches over, so the +corpus, chunking, and embeddings are provably identical across arms. +``BaseRetriever`` owns the encode -> (optional) rerank -> select pipeline; each +subclass only customises how the selected chunks become an LLM context string. +""" + +from __future__ import annotations + +import pickle +from abc import ABC, abstractmethod +from dataclasses import dataclass + +import numpy as np + +from ..config import TOP_K_CANDIDATES, TOP_K_FINAL +from ..llm import call_ollama +from ..prompts import BENCHMARK_SYSTEM_PROMPT, build_prompt + + +@dataclass +class Candidate: + """A retrieved chunk plus its provenance.""" + + chunk_id: str # ArangoDB _id or local id, e.g. "Chunks/12345_0" + paper_key: str # owning paper, e.g. "12345" + text: str + score: float = 0.0 + + +def _normalize(matrix: np.ndarray) -> np.ndarray: + norms = np.linalg.norm(matrix, axis=1, keepdims=True) + norms[norms == 0] = 1.0 + return matrix / norms + + +class ChunkStore: + """In-memory, L2-normalised chunk embeddings with cosine search.""" + + def __init__(self, ids: list[str], paper_keys: list[str], + texts: list[str], embeddings: np.ndarray): + self.ids = ids + self.paper_keys = paper_keys + self.texts = texts + self.embeddings = _normalize(np.asarray(embeddings, dtype=np.float32)) \ + if len(embeddings) else np.zeros((0, 0), dtype=np.float32) + + def __len__(self) -> int: + return len(self.ids) + + def search(self, query_emb: np.ndarray, k: int) -> list[int]: + """Return indices of the top-k chunks by cosine similarity.""" + if len(self) == 0: + return [] + q = _normalize(np.atleast_2d(np.asarray(query_emb, dtype=np.float32))) + sims = (self.embeddings @ q[0]) + k = min(k, len(self)) + top = np.argpartition(sims, -k)[-k:] + return list(top[np.argsort(sims[top])[::-1]]) + + def candidate(self, idx: int, score: float = 0.0) -> Candidate: + return Candidate(self.ids[idx], self.paper_keys[idx], self.texts[idx], score) + + # โ”€โ”€ builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + @classmethod + def from_dataset(cls, encoder, include_unlabeled: bool = True, + batch_size: int = 128) -> ChunkStore: + """Build the corpus locally from PubMedQA (no ArangoDB needed).""" + from ..data import iter_chunks + + ids, paper_keys, texts = [], [], [] + for paper_key, chunk_idx, text in iter_chunks(include_unlabeled): + ids.append(f"Chunks/{paper_key}_{chunk_idx}") + paper_keys.append(paper_key) + texts.append(text) + embeddings = encoder.encode( + texts, batch_size=batch_size, convert_to_numpy=True, + normalize_embeddings=True, show_progress_bar=True, + ) + return cls(ids, paper_keys, texts, embeddings) + + @classmethod + def from_arango(cls, db, collection: str = "Chunks", batch: int = 5000, + cache_file: str | None = None) -> ChunkStore: + """Download chunk vectors from ArangoDB (with optional pickle cache).""" + if cache_file: + import os + + if os.path.exists(cache_file): + with open(cache_file, "rb") as f: + data = pickle.load(f) + if len(data["embeddings"]): + return cls(data["ids"], data["paper_keys"], + data["texts"], np.asarray(data["embeddings"])) + + ids, paper_keys, texts, embeddings = [], [], [], [] + offset = 0 + while True: + aql = f""" + FOR c IN {collection} + FILTER c.embedding != null + LIMIT {offset}, {batch} + RETURN {{ id: c._id, paper: c.paper_key, + text: c.text, emb: c.embedding }} + """ + page = list(db.aql.execute(aql, ttl=3600)) + if not page: + break + for doc in page: + ids.append(doc["id"]) + paper_keys.append(doc.get("paper") or doc["id"].split("/")[-1].rsplit("_", 1)[0]) + texts.append(doc["text"]) + embeddings.append(doc["emb"]) + offset += len(page) + if len(page) < batch: + break + + embeddings_np = np.asarray(embeddings, dtype=np.float32) + if cache_file and ids: + with open(cache_file, "wb") as f: + pickle.dump({"ids": ids, "paper_keys": paper_keys, + "texts": texts, "embeddings": embeddings_np}, f) + return cls(ids, paper_keys, texts, embeddings_np) + + +class BaseRetriever(ABC): + """encode -> (optional) rerank -> select -> build context -> answer.""" + + name: str = "base" + + def __init__(self, store: ChunkStore, encoder, reranker=None, + top_k_final: int = TOP_K_FINAL, + top_k_candidates: int = TOP_K_CANDIDATES): + self.store = store + self.encoder = encoder + self.reranker = reranker + self.top_k_final = top_k_final + self.top_k_candidates = top_k_candidates + + def _select(self, query: str) -> list[Candidate]: + """Top-k chunks, optionally cross-encoder reranked from a wide pool.""" + query_emb = self.encoder.encode([query], normalize_embeddings=True) + pool_k = self.top_k_candidates if self.reranker else self.top_k_final + idxs = self.store.search(query_emb, pool_k) + candidates = [self.store.candidate(i) for i in idxs] + + if self.reranker and candidates: + scores = self.reranker.predict([[query, c.text] for c in candidates]) + order = np.argsort(scores)[::-1][:self.top_k_final] + return [ + Candidate(candidates[i].chunk_id, candidates[i].paper_key, + candidates[i].text, float(scores[i])) + for i in order + ] + return candidates[:self.top_k_final] + + @abstractmethod + def _build_context(self, query: str, candidates: list[Candidate]) -> str: + """Turn selected chunks into the LLM context string.""" + + def retrieve(self, query: str) -> str: + return self._build_context(query, self._select(query)) + + def answer_benchmark(self, question: str) -> str: + context = self.retrieve(question) + return call_ollama(build_prompt(context, question), + system=BENCHMARK_SYSTEM_PROMPT) diff --git a/src/kgqa/retrieval/graph.py b/src/kgqa/retrieval/graph.py new file mode 100644 index 0000000..5cf4e13 --- /dev/null +++ b/src/kgqa/retrieval/graph.py @@ -0,0 +1,118 @@ +"""GraphRAG arms: ``graph`` (parent expansion) and ``graph_concepts``. + +Both reuse the identical encode + rerank + select pipeline from ``BaseRetriever`` +(so the reranker is *controlled for*, not a confound). The graph then adds: + + graph parent-paper expansion โ€” reconstruct each selected chunk's + full abstract via HAS_CONTEXT traversal. + graph_concepts the above, plus a MeSH concept hop โ€” pull in a few related + papers that share concepts with the selected papers. + +Leakage is stripped: studies are labelled generically ("=== STUDY n ===") and +no question-derived title or ``final_decision`` ever reaches the prompt. +""" + +from __future__ import annotations + +from ..config import CONCEPT_HOP_PAPERS, HAS_CONTEXT, MENTIONS +from .base import BaseRetriever, Candidate + +# Reconstruct the full abstract of each selected chunk's parent paper. +_PARENT_AQL = """ + WITH Papers, Chunks + FOR cid IN @ids + LET chunk = DOCUMENT(cid) + FOR paper IN 1..1 INBOUND chunk @@has_context + LET sections = ( + FOR c IN 1..1 OUTBOUND paper @@has_context + SORT c._key + RETURN c.text + ) + RETURN DISTINCT { + paper: paper._key, + abstract: CONCAT_SEPARATOR(" ", sections) + } +""" + +# From the seed papers, hop across shared MeSH concepts to related papers. +_CONCEPT_AQL = """ + WITH Papers, Chunks, Concepts + LET seeds = @paper_keys + FOR pkey IN seeds + LET paper = DOCUMENT(CONCAT("Papers/", pkey)) + FILTER paper != null + FOR concept IN 1..1 OUTBOUND paper @@mentions + FOR neighbour IN 1..1 INBOUND concept @@mentions + FILTER neighbour._key NOT IN seeds + LET sections = ( + FOR c IN 1..1 OUTBOUND neighbour @@has_context + SORT c._key + RETURN c.text + ) + COLLECT npaper = neighbour._key, + abstract = CONCAT_SEPARATOR(" ", sections) + WITH COUNT INTO shared + SORT shared DESC + LIMIT @limit + RETURN { paper: npaper, abstract: abstract, shared: shared } +""" + + +class GraphRetriever(BaseRetriever): + name = "graph" + + def __init__(self, store, encoder, db, reranker=None, + use_concepts: bool = False, + concept_hop_papers: int = CONCEPT_HOP_PAPERS, **kwargs): + super().__init__(store, encoder, reranker=reranker, **kwargs) + self.db = db + self.use_concepts = use_concepts + self.concept_hop_papers = concept_hop_papers + if use_concepts: + self.name = "graph_concepts" + + def _parent_abstracts(self, chunk_ids: list[str]) -> list[tuple[str, str]]: + rows = self.db.aql.execute( + _PARENT_AQL, + bind_vars={"ids": chunk_ids, "@has_context": HAS_CONTEXT}, + ) + out, seen = [], set() + for row in rows: + key = row["paper"] + if key in seen: + continue + seen.add(key) + out.append((key, row.get("abstract", ""))) + return out + + def _concept_neighbours(self, paper_keys: list[str]) -> list[tuple[str, str]]: + rows = self.db.aql.execute( + _CONCEPT_AQL, + bind_vars={ + "paper_keys": paper_keys, + "@mentions": MENTIONS, + "@has_context": HAS_CONTEXT, + "limit": self.concept_hop_papers, + }, + ) + return [(row["paper"], row.get("abstract", "")) for row in rows] + + def _build_context(self, query: str, candidates: list[Candidate]) -> str: + chunk_ids = [c.chunk_id for c in candidates] + try: + studies = self._parent_abstracts(chunk_ids) + seed_keys = [k for k, _ in studies] + if self.use_concepts and seed_keys: + for key, abstract in self._concept_neighbours(seed_keys): + if key not in seed_keys and abstract: + studies.append((key, abstract)) + except Exception as exc: # graph unreachable -> degrade to raw chunks + print(f"[GraphRAG] Graph expansion failed ({exc}). Using raw chunks.") + studies = [(c.paper_key, c.text) for c in candidates] + + parts = [ + f"=== STUDY {i + 1} ===\n{abstract}" + for i, (_key, abstract) in enumerate(studies) + if abstract + ] + return "\n\n".join(parts) if parts else "No context found." diff --git a/src/kgqa/retrieval/plain.py b/src/kgqa/retrieval/plain.py new file mode 100644 index 0000000..15b6aa1 --- /dev/null +++ b/src/kgqa/retrieval/plain.py @@ -0,0 +1,27 @@ +"""PlainRAG arms: ``plain`` (no rerank) and ``plain_rr`` (with rerank). + +Context is the raw retrieved chunk text โ€” no graph structure is used. With +``reranker=None`` this is the baseline; pass a CrossEncoder for the ``plain_rr`` +arm that isolates the reranker's contribution. +""" + +from __future__ import annotations + +from .base import BaseRetriever, Candidate + + +class PlainRetriever(BaseRetriever): + name = "plain" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.reranker is not None: + self.name = "plain_rr" + + def _build_context(self, query: str, candidates: list[Candidate]) -> str: + if not candidates: + return "No context available." + return "\n\n".join( + f"Abstract {i + 1}: {c.text}" + for i, c in enumerate(candidates) + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b17632d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,84 @@ +"""Shared fakes so the suite runs on CPU with no Ollama, ArangoDB, or ML deps.""" + +from __future__ import annotations + +import numpy as np +import pytest + + +class FakeEncoder: + """Deterministic hashing encoder โ€” stable vectors without downloading a model.""" + + dim = 16 + + def encode(self, texts, normalize_embeddings=False, convert_to_numpy=True, + batch_size=32, show_progress_bar=False): + single = isinstance(texts, str) + items = [texts] if single else list(texts) + vecs = np.zeros((len(items), self.dim), dtype=np.float32) + for i, t in enumerate(items): + for token in str(t).lower().split(): + vecs[i, hash(token) % self.dim] += 1.0 + if normalize_embeddings: + norms = np.linalg.norm(vecs, axis=1, keepdims=True) + norms[norms == 0] = 1.0 + vecs = vecs / norms + return vecs[0] if single else vecs + + +class FakeReranker: + """Scores by lexical overlap between query and candidate text.""" + + def predict(self, pairs): + scores = [] + for query, text in pairs: + q = set(str(query).lower().split()) + d = set(str(text).lower().split()) + scores.append(float(len(q & d))) + return np.array(scores) + + +class FakeAQL: + def __init__(self, db): + self.db = db + + def execute(self, query, bind_vars=None, **kwargs): + bind_vars = bind_vars or {} + # Parent expansion: map chunk ids -> parent paper full abstracts. + if "INBOUND chunk" in query: + seen, out = set(), [] + for cid in bind_vars["ids"]: + pkey = cid.split("/")[-1].rsplit("_", 1)[0] + if pkey in seen: + continue + seen.add(pkey) + out.append({"paper": pkey, "abstract": self.db.abstracts[pkey]}) + return out + # Concept hop: return configured neighbours for the seed papers. + if "@mentions" in query or "mentions" in query.lower(): + seeds = set(bind_vars["paper_keys"]) + out = [] + for nkey, abstract in self.db.neighbours: + if nkey not in seeds: + out.append({"paper": nkey, "abstract": abstract, "shared": 1}) + return out[: bind_vars.get("limit", 3)] + return [] + + +class FakeDB: + """Minimal ArangoDB stand-in for graph-expansion tests.""" + + def __init__(self, abstracts, neighbours=()): + self.abstracts = abstracts # {paper_key: full abstract} + self.neighbours = list(neighbours) # [(paper_key, abstract), ...] + self.aql = FakeAQL(self) + + +@pytest.fixture +def fake_encoder(): + return FakeEncoder() + + +@pytest.fixture +def fake_reranker(): + return FakeReranker() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..88fbecf --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,25 @@ +import pytest + +from kgqa.config import TOP_K_CANDIDATES, TOP_K_FINAL, ArangoConfig +from kgqa.prompts import build_prompt + + +def test_arango_requires_password(): + cfg = ArangoConfig(password="") + with pytest.raises(EnvironmentError): + cfg.require_password() + + +def test_arango_password_ok(): + ArangoConfig(password="secret").require_password() # no raise + + +def test_retrieval_constants_sane(): + assert TOP_K_FINAL >= 1 + assert TOP_K_CANDIDATES >= TOP_K_FINAL + + +def test_build_prompt_structure(): + p = build_prompt("CTX", "Q?") + assert "Context:\nCTX" in p + assert "Question: Q?" in p diff --git a/tests/test_evaluation.py b/tests/test_evaluation.py new file mode 100644 index 0000000..aa9c5d1 --- /dev/null +++ b/tests/test_evaluation.py @@ -0,0 +1,49 @@ +from kgqa.evaluation import Evaluator, FuzzyEvaluator, mcnemar_test + + +def test_extract_final_answer_tag(): + fz = FuzzyEvaluator() + assert fz.extract_answer("blah blah Final Answer: yes") == "yes" + assert fz.extract_answer("FINAL ANSWER : No") == "no" + + +def test_extract_strips_think_block(): + fz = FuzzyEvaluator() + text = "maybe yes no The study shows ... Final Answer: maybe" + assert fz.extract_answer(text) == "maybe" + + +def test_extract_falls_back_to_last_mention(): + fz = FuzzyEvaluator() + assert fz.extract_answer("I think the answer is no") == "no" + assert fz.extract_answer("nothing useful here") == "maybe" + + +def test_evaluator_metrics_and_normalisation(): + ev = Evaluator("plain") + ev.record("yes", "yes", 1.0, sample_id="1") + ev.record("no", "garbage", 2.0, sample_id="2") # invalid -> maybe + ev.record("maybe", "maybe", 3.0, sample_id="3") + s = ev.summary() + assert s["samples"] == 3 + assert s["y_pred"][1] == "maybe" + assert abs(s["accuracy"] - 2 / 3) < 1e-9 + assert abs(s["avg_latency"] - 2.0) < 1e-9 + assert s["ids"] == ["1", "2", "3"] + + +def test_mcnemar_detects_one_sided_gain(): + gt = ["yes"] * 10 + a = ["no"] * 10 # arm A always wrong + b = ["yes"] * 10 # arm B always right + res = mcnemar_test(gt, a, b) + assert res["b_gains"] == 10 + assert res["c_losses"] == 0 + assert res["significant_at_0.05"] is True + + +def test_mcnemar_no_difference(): + gt = ["yes", "no", "maybe"] + res = mcnemar_test(gt, gt, gt) + assert res["discordant"] == 0 + assert res["p_value"] == 1.0 diff --git a/tests/test_retrieval.py b/tests/test_retrieval.py new file mode 100644 index 0000000..9cbca7a --- /dev/null +++ b/tests/test_retrieval.py @@ -0,0 +1,88 @@ +import numpy as np + +from kgqa.retrieval import ChunkStore, GraphRetriever, PlainRetriever +from tests.conftest import FakeDB + + +def make_store(encoder): + texts = [ + "aspirin reduces heart attack risk in patients", + "statins lower cholesterol levels significantly", + "regular exercise improves mood and sleep", + ] + keys = ["1", "2", "3"] + ids = [f"Chunks/{k}_0" for k in keys] + embs = encoder.encode(texts, normalize_embeddings=True) + return ChunkStore(ids, keys, texts, np.asarray(embs)) + + +def test_chunkstore_search_ranks_relevant_first(fake_encoder): + store = make_store(fake_encoder) + idxs = store.search(fake_encoder.encode(["aspirin heart attack"]), k=3) + assert store.paper_keys[idxs[0]] == "1" + + +def test_plain_arm_naming(fake_encoder, fake_reranker): + assert PlainRetriever(make_store(fake_encoder), fake_encoder).name == "plain" + assert PlainRetriever(make_store(fake_encoder), fake_encoder, + reranker=fake_reranker).name == "plain_rr" + + +def test_plain_context_is_raw_chunks(fake_encoder): + store = make_store(fake_encoder) + r = PlainRetriever(store, fake_encoder, top_k_final=1) + ctx = r.retrieve("aspirin heart attack") + assert ctx.startswith("Abstract 1:") + assert "aspirin" in ctx + + +def test_graph_parent_expansion_uses_full_abstract(fake_encoder, fake_reranker): + store = make_store(fake_encoder) + db = FakeDB(abstracts={ + "1": "FULL ABSTRACT 1: aspirin trial methods results conclusion", + "2": "FULL ABSTRACT 2: statin trial", + "3": "FULL ABSTRACT 3: exercise study", + }) + r = GraphRetriever(store, fake_encoder, db, reranker=fake_reranker, top_k_final=1) + assert r.name == "graph" + ctx = r.retrieve("aspirin heart attack") + assert "=== STUDY 1 ===" in ctx + assert "FULL ABSTRACT 1" in ctx + + +def test_graph_concept_hop_adds_neighbour(fake_encoder, fake_reranker): + store = make_store(fake_encoder) + db = FakeDB( + abstracts={"1": "FULL ABSTRACT 1: aspirin", "2": "x", "3": "y"}, + neighbours=[("99", "NEIGHBOUR ABSTRACT via shared MeSH concept")], + ) + r = GraphRetriever(store, fake_encoder, db, reranker=fake_reranker, + use_concepts=True, top_k_final=1) + assert r.name == "graph_concepts" + ctx = r.retrieve("aspirin heart attack") + assert "NEIGHBOUR ABSTRACT" in ctx + assert ctx.count("=== STUDY") == 2 + + +def test_graph_context_has_no_question_leakage(fake_encoder, fake_reranker): + """The benchmark question/title must never appear in the graph context.""" + store = make_store(fake_encoder) + db = FakeDB(abstracts={"1": "FULL ABSTRACT 1: aspirin", "2": "x", "3": "y"}) + r = GraphRetriever(store, fake_encoder, db, reranker=fake_reranker, top_k_final=1) + question = "does aspirin reduce heart attack risk" + ctx = r.retrieve(question) + assert question not in ctx + assert "STUDY:" not in ctx # old leaky "=== STUDY: {title} ===" format is gone + + +def test_graph_degrades_to_raw_chunks_on_db_error(fake_encoder, fake_reranker): + class BrokenDB: + class aql: + @staticmethod + def execute(*a, **k): + raise RuntimeError("no connection") + store = make_store(fake_encoder) + r = GraphRetriever(store, fake_encoder, BrokenDB(), reranker=fake_reranker, top_k_final=1) + ctx = r.retrieve("aspirin heart attack") + assert "=== STUDY 1 ===" in ctx + assert "aspirin" in ctx From d78dcdbe3ca7d31de9220812cfe3e51dc9ef57eb Mon Sep 17 00:00:00 2001 From: vardhjain Date: Fri, 12 Jun 2026 13:27:15 -0400 Subject: [PATCH 05/23] Tighten concept-hop query and drop dead FAISS dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - concept-hop AQL now ranks neighbours by shared-concept count first and reconstructs abstracts only for the top-N (was building an abstract for every candidate on every query โ€” 200x on a full run) - remove faiss-cpu: PlainRAG now uses the shared numpy-cosine ChunkStore --- requirements.txt | 3 --- src/kgqa/retrieval/graph.py | 39 +++++++++++++++++++++---------------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/requirements.txt b/requirements.txt index d0333e0..40a5ff9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,9 +5,6 @@ numpy>=1.24 scikit-learn>=1.3 scipy>=1.10 -# Vector index (PlainRAG local fallback) -faiss-cpu>=1.7.4 - # Knowledge graph python-arango>=7.9.0 diff --git a/src/kgqa/retrieval/graph.py b/src/kgqa/retrieval/graph.py index 5cf4e13..03009fd 100644 --- a/src/kgqa/retrieval/graph.py +++ b/src/kgqa/retrieval/graph.py @@ -35,26 +35,31 @@ """ # From the seed papers, hop across shared MeSH concepts to related papers. +# Two-stage: rank neighbours by how many concepts they share with the seeds +# (cheap), then reconstruct abstracts only for the top-N (avoids building an +# abstract for every candidate on every query). _CONCEPT_AQL = """ WITH Papers, Chunks, Concepts LET seeds = @paper_keys - FOR pkey IN seeds - LET paper = DOCUMENT(CONCAT("Papers/", pkey)) - FILTER paper != null - FOR concept IN 1..1 OUTBOUND paper @@mentions - FOR neighbour IN 1..1 INBOUND concept @@mentions - FILTER neighbour._key NOT IN seeds - LET sections = ( - FOR c IN 1..1 OUTBOUND neighbour @@has_context - SORT c._key - RETURN c.text - ) - COLLECT npaper = neighbour._key, - abstract = CONCAT_SEPARATOR(" ", sections) - WITH COUNT INTO shared - SORT shared DESC - LIMIT @limit - RETURN { paper: npaper, abstract: abstract, shared: shared } + LET ranked = ( + FOR pkey IN seeds + LET paper = DOCUMENT(CONCAT("Papers/", pkey)) + FILTER paper != null + FOR concept IN 1..1 OUTBOUND paper @@mentions + FOR neighbour IN 1..1 INBOUND concept @@mentions + FILTER neighbour._key NOT IN seeds + COLLECT nkey = neighbour._key WITH COUNT INTO shared + SORT shared DESC + LIMIT @limit + RETURN { nkey: nkey, shared: shared } + ) + FOR n IN ranked + LET sections = ( + FOR c IN 1..1 OUTBOUND DOCUMENT(CONCAT("Papers/", n.nkey)) @@has_context + SORT c._key + RETURN c.text + ) + RETURN { paper: n.nkey, abstract: CONCAT_SEPARATOR(" ", sections), shared: n.shared } """ From 45a3b3e3a545ca1a88ce0a7ac9799f9758db6401 Mon Sep 17 00:00:00 2001 From: vardhjain Date: Fri, 12 Jun 2026 13:33:49 -0400 Subject: [PATCH 06/23] Point at new ArangoDB deployment; tune notebooks for Colab Pro A100 - default ARANGO_HOST -> new Oasis deployment (581c546a8d66), overridable via env - notebooks request A100 GPU + High-RAM; add nvidia-smi check and a labeled-only ingest smoke test before the full run --- .env.example | 2 +- notebooks/01_ingest.ipynb | 29 ++++++++++++++++++++--------- notebooks/02_benchmark.ipynb | 22 ++++++++++++++++++---- src/kgqa/config.py | 2 +- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index e309334..6a5ce5f 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ # On Google Colab, set these via the Secrets panel (key icon) instead. # โ”€โ”€ ArangoDB Oasis (GraphRAG only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -ARANGO_HOST=https://YOUR-DEPLOYMENT.arangodb.cloud:8529 +ARANGO_HOST=https://581c546a8d66.arangodb.cloud:8529 ARANGO_USER=root ARANGO_PASS= ARANGO_DB=pubmed_graph diff --git a/notebooks/01_ingest.ipynb b/notebooks/01_ingest.ipynb index d9d7801..2e16ed0 100644 --- a/notebooks/01_ingest.ipynb +++ b/notebooks/01_ingest.ipynb @@ -9,11 +9,11 @@ "Thin Colab wrapper around `scripts/ingest.py`. Builds the **leakage-free** knowledge graph\n", "(Papers / Chunks / Concepts + HAS_CONTEXT / MENTIONS edges).\n", "\n", - "**Before running:** add `ARANGO_PASS` (and optionally `ARANGO_HOST`) in the Colab\n", - "**Secrets** panel (key icon, left sidebar). Runtime type can be CPU โ€” ingestion is embedding-bound\n", - "but a GPU runtime makes it faster.\n", + "**Colab Pro:** a **GPU** runtime (A100/L4) + **High-RAM** speeds the embedding pass.\n", + "Add `ARANGO_PASS` in the Colab **Secrets** panel (key icon, left sidebar).\n", "\n", - "Run this notebook **once**, then use `02_benchmark.ipynb`." + "Run this notebook **once** against the (empty) deployment, then use `02_benchmark.ipynb`.\n", + "Ingesting labeled + unlabeled (~62k papers) is mostly network-bound on the inserts." ] }, { @@ -38,9 +38,8 @@ "from google.colab import userdata\n", "\n", "os.environ['ARANGO_PASS'] = userdata.get('ARANGO_PASS')\n", - "# Optional overrides if your deployment differs from the defaults:\n", - "# os.environ['ARANGO_HOST'] = userdata.get('ARANGO_HOST')\n", - "# os.environ['ARANGO_DB'] = 'pubmed_graph'\n", + "# Endpoint is baked into kgqa.config; override here only if your deployment differs:\n", + "# os.environ['ARANGO_HOST'] = 'https://581c546a8d66.arangodb.cloud:8529'\n", "print('ARANGO_PASS set:', bool(os.environ.get('ARANGO_PASS')))" ] }, @@ -50,13 +49,25 @@ "metadata": {}, "outputs": [], "source": [ - "# Full ingestion (labeled + unlabeled). Add --no-unlabeled for a quick smoke test.\n", + "# Quick smoke test first (labeled split only, ~1k papers) to confirm the\n", + "# connection + schema before the full run:\n", + "!python scripts/ingest.py --no-unlabeled" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Full ingestion (labeled + unlabeled). Safe to re-run: papers/chunks upsert by key.\n", "!python scripts/ingest.py" ] } ], "metadata": { - "colab": {"provenance": []}, + "accelerator": "GPU", + "colab": {"provenance": [], "gpuType": "A100", "machine_shape": "hm"}, "kernelspec": {"display_name": "Python 3", "name": "python3"}, "language_info": {"name": "python"} }, diff --git a/notebooks/02_benchmark.ipynb b/notebooks/02_benchmark.ipynb index 65b3e63..be3acd1 100644 --- a/notebooks/02_benchmark.ipynb +++ b/notebooks/02_benchmark.ipynb @@ -8,8 +8,10 @@ "\n", "Thin Colab wrapper around `scripts/run_benchmark.py` and `scripts/compare.py`.\n", "\n", - "**Use a GPU runtime** (Runtime โ†’ Change runtime type โ†’ T4 GPU). Add `ARANGO_PASS`\n", - "in the Colab **Secrets** panel. Run `01_ingest.ipynb` first.\n", + "**Colab Pro:** Runtime โ†’ Change runtime type โ†’ **A100 GPU** + **High-RAM**. This run is\n", + "LLM-inference-bound (~800 generations from a reasoning model), so the faster GPU is what\n", + "cuts wall-clock; High-RAM holds the chunk matrix comfortably. Add `ARANGO_PASS` in the\n", + "Colab **Secrets** panel. Run `01_ingest.ipynb` first.\n", "\n", "Arms (each isolates one component):\n", "\n", @@ -20,7 +22,17 @@ "| `graph` | + parent-paper expansion (full abstracts) |\n", "| `graph_concepts` | + MeSH concept-hop expansion |\n", "\n", - "Full run is ~2โ€“4 hrs (LLM-bound). Lower `--n` for a faster pass." + "On A100 expect roughly ~1.5โ€“2.5 hrs for all four arms at `--n 200`. Lower `--n` for a faster pass." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Confirm the GPU (expect A100).\n", + "!nvidia-smi --query-gpu=name,memory.total --format=csv" ] }, { @@ -59,6 +71,8 @@ "import os\n", "from google.colab import userdata\n", "os.environ['ARANGO_PASS'] = userdata.get('ARANGO_PASS')\n", + "# Endpoint is baked into kgqa.config; override here only if your deployment differs:\n", + "# os.environ['ARANGO_HOST'] = 'https://581c546a8d66.arangodb.cloud:8529'\n", "print('ARANGO_PASS set:', bool(os.environ.get('ARANGO_PASS')))" ] }, @@ -102,7 +116,7 @@ ], "metadata": { "accelerator": "GPU", - "colab": {"provenance": [], "gpuType": "T4"}, + "colab": {"provenance": [], "gpuType": "A100", "machine_shape": "hm"}, "kernelspec": {"display_name": "Python 3", "name": "python3"}, "language_info": {"name": "python"} }, diff --git a/src/kgqa/config.py b/src/kgqa/config.py index 4b15232..73b1012 100644 --- a/src/kgqa/config.py +++ b/src/kgqa/config.py @@ -54,7 +54,7 @@ class ArangoConfig: """ArangoDB Oasis connection settings, read from the environment.""" host: str = field(default_factory=lambda: os.environ.get( - "ARANGO_HOST", "https://localhost:8529")) + "ARANGO_HOST", "https://581c546a8d66.arangodb.cloud:8529")) user: str = field(default_factory=lambda: os.environ.get("ARANGO_USER", "root")) password: str = field(default_factory=lambda: os.environ.get("ARANGO_PASS", "")) db_name: str = field(default_factory=lambda: os.environ.get("ARANGO_DB", "pubmed_graph")) From dc7e9db09c10039760b5684cfd607b98d06d5110 Mon Sep 17 00:00:00 2001 From: vardhjain Date: Fri, 12 Jun 2026 15:49:22 -0400 Subject: [PATCH 07/23] Make benchmark resilient to Ollama crashes; cap generation A single Ollama 500/timeout previously aborted an entire arm, and since the server is shared across arms one crash cascaded into all four failing. - run_benchmark: per-question retry (3x) with automatic Ollama restart between attempts; checkpoint results every 25 questions; the script now owns Ollama health (health-check + (re)start + warm) instead of relying on a one-shot start - llm: cap generation via num_predict and keep the model resident (keep_alive), so a runaway reasoning chain can't stall/crash the server - config: LLM_NUM_CTX / LLM_NUM_PREDICT / LLM_KEEP_ALIVE / LLM_TIMEOUT now env-tunable (shrink on small-VRAM GPUs); defaults 4096 / 1024 / 30m / 180s - notebook: drop --no-ollama-start so the runner can self-heal; add a GPU-memory tuning hint --- notebooks/02_benchmark.ipynb | 22 +++++--- scripts/run_benchmark.py | 103 +++++++++++++++++++++++++++++------ src/kgqa/config.py | 10 +++- src/kgqa/llm.py | 17 +++++- 4 files changed, 122 insertions(+), 30 deletions(-) diff --git a/notebooks/02_benchmark.ipynb b/notebooks/02_benchmark.ipynb index be3acd1..1ba9b6a 100644 --- a/notebooks/02_benchmark.ipynb +++ b/notebooks/02_benchmark.ipynb @@ -10,8 +10,7 @@ "\n", "**Colab Pro:** Runtime โ†’ Change runtime type โ†’ **A100 GPU** + **High-RAM**. This run is\n", "LLM-inference-bound (~800 generations from a reasoning model), so the faster GPU is what\n", - "cuts wall-clock; High-RAM holds the chunk matrix comfortably. Add `ARANGO_PASS` in the\n", - "Colab **Secrets** panel. Run `01_ingest.ipynb` first.\n", + "cuts wall-clock. Add `ARANGO_PASS` in the Colab **Secrets** panel. Run `01_ingest.ipynb` first.\n", "\n", "Arms (each isolates one component):\n", "\n", @@ -22,7 +21,8 @@ "| `graph` | + parent-paper expansion (full abstracts) |\n", "| `graph_concepts` | + MeSH concept-hop expansion |\n", "\n", - "On A100 expect roughly ~1.5โ€“2.5 hrs for all four arms at `--n 200`. Lower `--n` for a faster pass." + "The runner retries failed questions and auto-restarts Ollama if it crashes, and it\n", + "checkpoints every 25 questions โ€” so a transient 500 can't abort an arm." ] }, { @@ -31,7 +31,7 @@ "metadata": {}, "outputs": [], "source": [ - "# Confirm the GPU (expect A100).\n", + "# Confirm the GPU. A100 is ideal; T4/L4 also work (slower).\n", "!nvidia-smi --query-gpu=name,memory.total --format=csv" ] }, @@ -53,9 +53,11 @@ "metadata": {}, "outputs": [], "source": [ - "# Install + start Ollama, pull the LLM.\n", + "# Install Ollama and pull the LLM (once). The benchmark script manages the\n", + "# server from here on (health-check + auto-restart).\n", "!apt-get install -y zstd -q\n", "!curl -fsSL https://ollama.com/install.sh | sh\n", + "!ollama --version || true\n", "import subprocess, time\n", "subprocess.Popen(['ollama', 'serve'])\n", "time.sleep(5)\n", @@ -71,8 +73,9 @@ "import os\n", "from google.colab import userdata\n", "os.environ['ARANGO_PASS'] = userdata.get('ARANGO_PASS')\n", - "# Endpoint is baked into kgqa.config; override here only if your deployment differs:\n", - "# os.environ['ARANGO_HOST'] = 'https://581c546a8d66.arangodb.cloud:8529'\n", + "# On a small-VRAM GPU (e.g. T4) you can shrink generation if you hit OOM 500s:\n", + "# os.environ['LLM_NUM_CTX'] = '4096' # default\n", + "# os.environ['LLM_NUM_PREDICT'] = '768' # default 1024\n", "print('ARANGO_PASS set:', bool(os.environ.get('ARANGO_PASS')))" ] }, @@ -83,10 +86,11 @@ "outputs": [], "source": [ "# Run all four arms. The chunk corpus is downloaded once and cached, then\n", - "# reused by every arm (identical corpus -> fair comparison).\n", + "# reused by every arm (identical corpus -> fair comparison). Each arm saves its\n", + "# own results JSON, so if one dies you can re-run just that arm.\n", "for arm in ['plain', 'plain_rr', 'graph', 'graph_concepts']:\n", " print(f'\\n===== {arm} =====')\n", - " !python scripts/run_benchmark.py --arm {arm} --n 200 --no-ollama-start" + " !python scripts/run_benchmark.py --arm {arm} --n 200" ] }, { diff --git a/scripts/run_benchmark.py b/scripts/run_benchmark.py index 8dade58..fa0f787 100644 --- a/scripts/run_benchmark.py +++ b/scripts/run_benchmark.py @@ -11,6 +11,10 @@ All arms share one ArangoDB-backed chunk corpus (cached locally), the same encoder, reranker, prompt, LLM, seed and sample โ€” so results are comparable and the only moving part is the retrieval strategy named by --arm. + +Resilience: each question is retried, and a wedged/crashed Ollama is restarted +between attempts, so a single 500/timeout cannot abort the whole arm. Partial +results are checkpointed every 25 questions. """ from __future__ import annotations @@ -21,10 +25,63 @@ import sys import time +import requests + ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.join(ROOT, "src")) ARMS = ("plain", "plain_rr", "graph", "graph_concepts") +MAX_TRIES = 3 +CHECKPOINT_EVERY = 25 + + +def _ollama_base(api_url: str) -> str: + return api_url.split("/api/")[0] + + +def _ollama_healthy(api_url: str, timeout: int = 5) -> bool: + try: + return requests.get(_ollama_base(api_url) + "/api/tags", timeout=timeout).ok + except Exception: + return False + + +def ensure_ollama(api_url: str, model: str, restart: bool = False, wait: int = 90) -> bool: + """Make sure a healthy Ollama is serving; (re)start it if not.""" + import shutil + + if restart: + try: + subprocess.run(["pkill", "-f", "ollama"], capture_output=True) + time.sleep(3) + except FileNotFoundError: + pass # no pkill (e.g. Windows) โ€” fall through and try to start + + if not restart and _ollama_healthy(api_url): + return True + + ollama = shutil.which("ollama") or "/usr/local/bin/ollama" + try: + subprocess.Popen([ollama, "serve"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except FileNotFoundError: + print("[Ollama] binary not found; assuming a server is reachable elsewhere.") + + deadline = time.time() + wait + while time.time() < deadline: + if _ollama_healthy(api_url): + try: # warm the model so the next real call isn't a cold load + requests.post( + _ollama_base(api_url) + "/api/generate", + json={"model": model, "prompt": "ok", "stream": False, + "keep_alive": "30m", "options": {"num_predict": 1}}, + timeout=180, + ) + except Exception: + pass + return True + time.sleep(2) + print("[Ollama] WARNING: server did not become healthy in time.") + return False def build_retriever(arm, store, encoder, reranker, db): @@ -49,24 +106,19 @@ def main(): parser.add_argument("--seed", type=int, default=None, help="random seed (default: config RANDOM_SEED)") parser.add_argument("--output", default=None, help="results JSON path") parser.add_argument("--no-ollama-start", action="store_true", - help="don't auto-start the Ollama server") + help="don't auto-start/health-check the Ollama server") args = parser.parse_args() - if not args.no_ollama_start: - print("[Ollama] Starting server (idempotent)...") - try: - subprocess.Popen(["ollama", "serve"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - time.sleep(3) - except FileNotFoundError: - print("[Ollama] 'ollama' not found on PATH; assuming it is already running.") - - from kgqa.config import BENCHMARK_N, RANDOM_SEED, ArangoConfig + from kgqa.config import BENCHMARK_N, LLM_MODEL, OLLAMA_API, RANDOM_SEED, ArangoConfig from kgqa.data import load_benchmark_samples from kgqa.evaluation import Evaluator, FuzzyEvaluator from kgqa.models import connect_arango, load_encoder, load_reranker from kgqa.retrieval import ChunkStore + if not args.no_ollama_start: + print("[Ollama] Ensuring server is healthy...") + ensure_ollama(OLLAMA_API, LLM_MODEL) + n = args.n or BENCHMARK_N seed = args.seed if args.seed is not None else RANDOM_SEED results_dir = os.path.join(ROOT, "results") @@ -80,8 +132,7 @@ def main(): print(f"[Corpus] {len(store):,} chunks loaded.") encoder = load_encoder() - needs_rerank = args.arm != "plain" - reranker = load_reranker() if needs_rerank else None + reranker = load_reranker() if args.arm != "plain" else None retriever = build_retriever(args.arm, store, encoder, reranker, db) samples = load_benchmark_samples(n=n, seed=seed) @@ -91,12 +142,30 @@ def main(): print(f"\n=== Benchmark: {args.arm} (n={len(samples)}, seed={seed}) ===") for i, s in enumerate(samples): t0 = time.time() - raw = retriever.answer_benchmark(s.question) + raw = None + for attempt in range(1, MAX_TRIES + 1): + try: + raw = retriever.answer_benchmark(s.question) + break + except Exception as exc: + print(f" [warn] q{i + 1} attempt {attempt}/{MAX_TRIES} failed: " + f"{type(exc).__name__}: {exc}") + if attempt < MAX_TRIES and not args.no_ollama_start: + ensure_ollama(OLLAMA_API, LLM_MODEL, restart=True) latency = time.time() - t0 - pred = fuzzy.extract_answer(raw) + + if raw is None: + pred = "maybe" # last resort so one bad call doesn't abort the arm + print(f"[{i + 1:3d}] GT={s.final_decision:<5} Pred={pred:<5} ! " + f"(skipped after {MAX_TRIES} tries)") + else: + pred = fuzzy.extract_answer(raw) + icon = "v" if pred == s.final_decision.lower().strip() else "x" + print(f"[{i + 1:3d}] GT={s.final_decision:<5} Pred={pred:<5} {icon} ({latency:.1f}s)") + evaluator.record(s.final_decision, pred, latency, sample_id=s.pubid) - icon = "v" if pred == s.final_decision.lower().strip() else "x" - print(f"[{i + 1:3d}] GT={s.final_decision:<5} Pred={pred:<5} {icon} ({latency:.1f}s)") + if (i + 1) % CHECKPOINT_EVERY == 0: + evaluator.save(out_path) # checkpoint partial progress evaluator.report() evaluator.save(out_path) diff --git a/src/kgqa/config.py b/src/kgqa/config.py index 73b1012..d3ce7c1 100644 --- a/src/kgqa/config.py +++ b/src/kgqa/config.py @@ -39,8 +39,14 @@ # โ”€โ”€ LLM serving โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ OLLAMA_API = os.environ.get("OLLAMA_API", "http://localhost:11434/api/chat") LLM_TEMPERATURE = 0.0 # deterministic for benchmarking -LLM_NUM_CTX = 4096 -LLM_TIMEOUT = 300 +# Env-tunable so the run can be sized to the GPU without code changes. num_predict +# caps generation so a runaway reasoning chain can't stall (or crash) the server; +# the answer extractor tolerates a truncated chain. Lower NUM_CTX to 4096 on a +# small-VRAM GPU (e.g. T4) if you hit out-of-memory 500s. +LLM_NUM_CTX = int(os.environ.get("LLM_NUM_CTX", "4096")) +LLM_NUM_PREDICT = int(os.environ.get("LLM_NUM_PREDICT", "1024")) +LLM_KEEP_ALIVE = os.environ.get("LLM_KEEP_ALIVE", "30m") +LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "180")) # โ”€โ”€ Graph schema (must match scripts/ingest.py) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ NODE_COLLECTIONS = ("Papers", "Chunks", "Concepts") diff --git a/src/kgqa/llm.py b/src/kgqa/llm.py index f2b03db..94b956c 100644 --- a/src/kgqa/llm.py +++ b/src/kgqa/llm.py @@ -4,7 +4,15 @@ import requests -from .config import LLM_MODEL, LLM_NUM_CTX, LLM_TEMPERATURE, LLM_TIMEOUT, OLLAMA_API +from .config import ( + LLM_KEEP_ALIVE, + LLM_MODEL, + LLM_NUM_CTX, + LLM_NUM_PREDICT, + LLM_TEMPERATURE, + LLM_TIMEOUT, + OLLAMA_API, +) def call_ollama( @@ -24,7 +32,12 @@ def call_ollama( "model": model, "messages": messages, "stream": False, - "options": {"temperature": temperature, "num_ctx": LLM_NUM_CTX}, + "keep_alive": LLM_KEEP_ALIVE, # keep the model resident across the run + "options": { + "temperature": temperature, + "num_ctx": LLM_NUM_CTX, + "num_predict": LLM_NUM_PREDICT, # cap generation so a call can't run away + }, } resp = requests.post(api_url, json=payload, timeout=LLM_TIMEOUT) resp.raise_for_status() From eb5194896e1a2abe516aa2dfef539e2e71e9824d Mon Sep 17 00:00:00 2001 From: vardhjain Date: Fri, 12 Jun 2026 16:22:04 -0400 Subject: [PATCH 08/23] Make notebook clones reset-safe; default to faster generation cap - clone cell now %cd /content + rm -rf before clone, so re-running can't nest a second checkout (caused a doubled results path) - benchmark secrets cell sets LLM_NUM_CTX=8192 / LLM_NUM_PREDICT=1024 for the 80GB A100: full graph context, bounded generation (~halves runtime; identical across arms so the comparison is unaffected) --- notebooks/01_ingest.ipynb | 7 +++++-- notebooks/02_benchmark.ipynb | 18 ++++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/notebooks/01_ingest.ipynb b/notebooks/01_ingest.ipynb index 2e16ed0..583b5eb 100644 --- a/notebooks/01_ingest.ipynb +++ b/notebooks/01_ingest.ipynb @@ -22,9 +22,12 @@ "metadata": {}, "outputs": [], "source": [ - "!git clone https://github.com/vardhjain/Knowledge_Graph_Question_Answering.git\n", + "# Reset-safe clone: always starts from /content and removes any prior copy,\n", + "# so re-running this cell can never nest a second checkout.\n", + "%cd /content\n", + "!rm -rf Knowledge_Graph_Question_Answering\n", + "!git clone -b revamp https://github.com/vardhjain/Knowledge_Graph_Question_Answering.git -q\n", "%cd Knowledge_Graph_Question_Answering\n", - "!git checkout revamp\n", "!pip install -q -r requirements.txt" ] }, diff --git a/notebooks/02_benchmark.ipynb b/notebooks/02_benchmark.ipynb index 1ba9b6a..3569419 100644 --- a/notebooks/02_benchmark.ipynb +++ b/notebooks/02_benchmark.ipynb @@ -41,9 +41,12 @@ "metadata": {}, "outputs": [], "source": [ - "!git clone https://github.com/vardhjain/Knowledge_Graph_Question_Answering.git\n", + "# Reset-safe clone: always starts from /content and removes any prior copy,\n", + "# so re-running this cell can never nest a second checkout.\n", + "%cd /content\n", + "!rm -rf Knowledge_Graph_Question_Answering\n", + "!git clone -b revamp https://github.com/vardhjain/Knowledge_Graph_Question_Answering.git -q\n", "%cd Knowledge_Graph_Question_Answering\n", - "!git checkout revamp\n", "!pip install -q -r requirements.txt" ] }, @@ -55,9 +58,7 @@ "source": [ "# Install Ollama and pull the LLM (once). The benchmark script manages the\n", "# server from here on (health-check + auto-restart).\n", - "!apt-get install -y zstd -q\n", - "!curl -fsSL https://ollama.com/install.sh | sh\n", - "!ollama --version || true\n", + "!which ollama || (apt-get install -y zstd -q && curl -fsSL https://ollama.com/install.sh | sh)\n", "import subprocess, time\n", "subprocess.Popen(['ollama', 'serve'])\n", "time.sleep(5)\n", @@ -73,9 +74,10 @@ "import os\n", "from google.colab import userdata\n", "os.environ['ARANGO_PASS'] = userdata.get('ARANGO_PASS')\n", - "# On a small-VRAM GPU (e.g. T4) you can shrink generation if you hit OOM 500s:\n", - "# os.environ['LLM_NUM_CTX'] = '4096' # default\n", - "# os.environ['LLM_NUM_PREDICT'] = '768' # default 1024\n", + "# 80GB A100: full context, generation bounded for speed (does not affect the\n", + "# comparison โ€” every arm shares these). Lower NUM_CTX to 4096 on a small GPU.\n", + "os.environ['LLM_NUM_CTX'] = '8192'\n", + "os.environ['LLM_NUM_PREDICT'] = '1024'\n", "print('ARANGO_PASS set:', bool(os.environ.get('ARANGO_PASS')))" ] }, From 704cdebc3423870147d81e9d30b3e618eedbf93e Mon Sep 17 00:00:00 2001 From: vardhjain <76175744+vardhjain@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:27:39 -0400 Subject: [PATCH 09/23] Created using Colab --- notebooks/02_benchmark.ipynb | 327 +++++++++++++++++++++-------------- 1 file changed, 198 insertions(+), 129 deletions(-) diff --git a/notebooks/02_benchmark.ipynb b/notebooks/02_benchmark.ipynb index 3569419..01aba82 100644 --- a/notebooks/02_benchmark.ipynb +++ b/notebooks/02_benchmark.ipynb @@ -1,131 +1,200 @@ { - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# 02 โ€” Benchmark: 4-arm GraphRAG vs PlainRAG ablation\n", - "\n", - "Thin Colab wrapper around `scripts/run_benchmark.py` and `scripts/compare.py`.\n", - "\n", - "**Colab Pro:** Runtime โ†’ Change runtime type โ†’ **A100 GPU** + **High-RAM**. This run is\n", - "LLM-inference-bound (~800 generations from a reasoning model), so the faster GPU is what\n", - "cuts wall-clock. Add `ARANGO_PASS` in the Colab **Secrets** panel. Run `01_ingest.ipynb` first.\n", - "\n", - "Arms (each isolates one component):\n", - "\n", - "| arm | adds |\n", - "| --- | --- |\n", - "| `plain` | vector top-k chunks (baseline) |\n", - "| `plain_rr` | + cross-encoder reranker |\n", - "| `graph` | + parent-paper expansion (full abstracts) |\n", - "| `graph_concepts` | + MeSH concept-hop expansion |\n", - "\n", - "The runner retries failed questions and auto-restarts Ollama if it crashes, and it\n", - "checkpoints every 25 questions โ€” so a transient 500 can't abort an arm." - ] + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "id": "XZ4YK8X6grCa" + }, + "source": [ + "# 02 โ€” Benchmark: 4-arm GraphRAG vs PlainRAG ablation\n", + "\n", + "Thin Colab wrapper around `scripts/run_benchmark.py` and `scripts/compare.py`.\n", + "\n", + "**Colab Pro:** Runtime โ†’ Change runtime type โ†’ **A100 GPU** + **High-RAM**. This run is\n", + "LLM-inference-bound (~800 generations from a reasoning model), so the faster GPU is what\n", + "cuts wall-clock. Add `ARANGO_PASS` in the Colab **Secrets** panel. Run `01_ingest.ipynb` first.\n", + "\n", + "Arms (each isolates one component):\n", + "\n", + "| arm | adds |\n", + "| --- | --- |\n", + "| `plain` | vector top-k chunks (baseline) |\n", + "| `plain_rr` | + cross-encoder reranker |\n", + "| `graph` | + parent-paper expansion (full abstracts) |\n", + "| `graph_concepts` | + MeSH concept-hop expansion |\n", + "\n", + "The runner retries failed questions and auto-restarts Ollama if it crashes, and it\n", + "checkpoints every 25 questions โ€” so a transient 500 can't abort an arm." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "1mxPGKyQgrCe" + }, + "outputs": [], + "source": [ + "# Confirm the GPU. A100 is ideal; T4/L4 also work (slower).\n", + "!nvidia-smi --query-gpu=name,memory.total --format=csv" + ] + }, + { + "cell_type": "code", + "source": [ + "# A) Clean reset โ†’ one current copy (removes the doubled folder)\n", + "%cd /content\n", + "!rm -rf Knowledge_Graph_Question_Answering\n", + "!git clone -b revamp https://github.com/vardhjain/Knowledge_Graph_Question_Answering.git -q\n", + "%cd Knowledge_Graph_Question_Answering\n", + "!pip install -q -r requirements.txt" + ], + "metadata": { + "id": "0AVI3iC3g5YW", + "outputId": "639a2492-c5e3-4916-90fc-b7d57629ee7e", + "colab": { + "base_uri": "https://localhost:8080/" + } + }, + "execution_count": 15, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "/content\n", + "/content/Knowledge_Graph_Question_Answering\n" + ] + } + ] + }, + { + "cell_type": "code", + "source": [ + "# B) Env โ€” full context, bounded generation (corpus re-downloads once this run)\n", + "import os\n", + "from google.colab import userdata\n", + "os.environ['ARANGO_PASS'] = userdata.get('ARANGO_PASS')\n", + "os.environ['LLM_NUM_CTX'] = '8192'\n", + "os.environ['LLM_NUM_PREDICT'] = '1024'\n", + "print('ARANGO_PASS set:', bool(os.environ.get('ARANGO_PASS')))" + ], + "metadata": { + "id": "E40igHZRg53_", + "outputId": "c0c59bcf-94f6-4aa4-b748-7411f90494e6", + "colab": { + "base_uri": "https://localhost:8080/" + } + }, + "execution_count": 16, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "ARANGO_PASS set: True\n" + ] + } + ] + }, + { + "cell_type": "code", + "source": [ + "# C) Ollama + model (idempotent; fast if already pulled this session)\n", + "!which ollama || (apt-get install -y zstd -q && curl -fsSL https://ollama.com/install.sh | sh)\n", + "import subprocess, time\n", + "subprocess.Popen(['ollama','serve']); time.sleep(5)\n", + "!ollama pull deepseek-r1:8b" + ], + "metadata": { + "id": "ASx016R5g9U9", + "outputId": "cb334177-cbbb-473b-d66b-17cbb865cfff", + "colab": { + "base_uri": "https://localhost:8080/" + } + }, + "execution_count": 17, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "/usr/local/bin/ollama\n", + "\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\n" + ] + } + ] + }, + { + "cell_type": "code", + "source": [ + "# D) Full run โ€” all four arms (~3 hrs on the 80GB A100)\n", + "for arm in ['plain', 'plain_rr', 'graph', 'graph_concepts']:\n", + " print(f'\\n===== {arm} =====')\n", + " !python scripts/run_benchmark.py --arm {arm} --n 200" + ], + "metadata": { + "id": "c50FRfHPg-vQ", + "outputId": "500d5a7b-91aa-4737-a956-c2941ff283b5", + "colab": { + "base_uri": "https://localhost:8080/" + } + }, + "execution_count": null, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "\n", + "===== plain =====\n", + "[Ollama] Ensuring server is healthy...\n", + "[ArangoDB] Connected.\n", + "[Corpus] Loading chunk store from ArangoDB (cached after first run)...\n" + ] + } + ] + }, + { + "cell_type": "code", + "source": [ + "# E) Aggregate โ†’ table, McNemar tests, figure\n", + "!python scripts/compare.py\n", + "from IPython.display import Image, display, Markdown\n", + "display(Markdown(open('results/summary.md').read()))\n", + "display(Image('results/ablation.png'))" + ], + "metadata": { + "id": "lxe8UUg0hANY" + }, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "source": [], + "metadata": { + "id": "eCMJwqcfhBll" + }, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "provenance": [], + "gpuType": "A100", + "machine_shape": "hm" + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Confirm the GPU. A100 is ideal; T4/L4 also work (slower).\n", - "!nvidia-smi --query-gpu=name,memory.total --format=csv" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Reset-safe clone: always starts from /content and removes any prior copy,\n", - "# so re-running this cell can never nest a second checkout.\n", - "%cd /content\n", - "!rm -rf Knowledge_Graph_Question_Answering\n", - "!git clone -b revamp https://github.com/vardhjain/Knowledge_Graph_Question_Answering.git -q\n", - "%cd Knowledge_Graph_Question_Answering\n", - "!pip install -q -r requirements.txt" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Install Ollama and pull the LLM (once). The benchmark script manages the\n", - "# server from here on (health-check + auto-restart).\n", - "!which ollama || (apt-get install -y zstd -q && curl -fsSL https://ollama.com/install.sh | sh)\n", - "import subprocess, time\n", - "subprocess.Popen(['ollama', 'serve'])\n", - "time.sleep(5)\n", - "!ollama pull deepseek-r1:8b" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "from google.colab import userdata\n", - "os.environ['ARANGO_PASS'] = userdata.get('ARANGO_PASS')\n", - "# 80GB A100: full context, generation bounded for speed (does not affect the\n", - "# comparison โ€” every arm shares these). Lower NUM_CTX to 4096 on a small GPU.\n", - "os.environ['LLM_NUM_CTX'] = '8192'\n", - "os.environ['LLM_NUM_PREDICT'] = '1024'\n", - "print('ARANGO_PASS set:', bool(os.environ.get('ARANGO_PASS')))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Run all four arms. The chunk corpus is downloaded once and cached, then\n", - "# reused by every arm (identical corpus -> fair comparison). Each arm saves its\n", - "# own results JSON, so if one dies you can re-run just that arm.\n", - "for arm in ['plain', 'plain_rr', 'graph', 'graph_concepts']:\n", - " print(f'\\n===== {arm} =====')\n", - " !python scripts/run_benchmark.py --arm {arm} --n 200" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Summary table, paired McNemar tests, and the ablation figure.\n", - "!python scripts/compare.py\n", - "from IPython.display import Image, display, Markdown\n", - "display(Markdown(open('results/summary.md').read()))\n", - "display(Image('results/ablation.png'))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Optional: commit results back to GitHub (set a PAT first).\n", - "# !git config user.email you@example.com && git config user.name you\n", - "# !git add results/ && git commit -m 'Add benchmark results' && git push" - ] - } - ], - "metadata": { - "accelerator": "GPU", - "colab": {"provenance": [], "gpuType": "A100", "machine_shape": "hm"}, - "kernelspec": {"display_name": "Python 3", "name": "python3"}, - "language_info": {"name": "python"} - }, - "nbformat": 4, - "nbformat_minor": 0 -} + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file From 585a24cc65c94c35743a687e8ac069241d54b0e0 Mon Sep 17 00:00:00 2001 From: vardhjain Date: Fri, 12 Jun 2026 20:05:48 -0400 Subject: [PATCH 10/23] Add benchmark results and honest ablation write-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit n=200, seed 42, deepseek-r1:8b on A100. Parent-document expansion is the decisive win (plain_rr -> graph +22.5pp, McNemar p<0.0001); reranker +7pp (n.s.); concept-hop -2pp (n.s.) at 5x latency. plain/plain_rr fall below the majority baseline โ€” context sufficiency, which the graph supplies, dominates. --- README.md | 66 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 63011ef..738fe76 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,12 @@ six issues; all are fixed in this revamp: | 5 | `Concepts` (MeSH) and `MENTIONS` edges were built but **never used** | The `graph_concepts` arm hops across shared MeSH concepts to pull in related papers | | 6 | `NameError` in the graph fallback; first-100 samples, no seed, no significance test | Fixed fallback; seeded random sample (default n=200); paired McNemar test | -**Honest expectation.** PubMedQA is mostly single-abstract QA, so a fair -parent-expansion gain may be modest. The interesting signal is in the -`graph_concepts` arm and in multi-evidence questions โ€” that is where a graph can -legitimately beat plain retrieval. The point of this repo is to measure that -honestly, not to manufacture a win. +**What we expected vs. what we found.** Going in, we expected concept-hop +expansion to be where the graph shines and a plain parent-expansion gain to be +modest. The data said the opposite: the decisive, statistically significant win +came from **parent-document expansion**, while concept-hop did not help on this +single-abstract dataset. We report that honestly rather than bury it โ€” see +[Results](#results). --- @@ -117,18 +118,49 @@ On Colab, run [`notebooks/01_ingest.ipynb`](notebooks/01_ingest.ipynb) once, the ## Results -> โณ **Pending the Colab benchmark run.** `scripts/compare.py` regenerates the -> table below into `results/summary.md` and `results/ablation.png`. - -| Arm | Accuracy | Macro F1 | What it isolates | -| --- | --- | --- | --- | -| `plain` | โ€” | โ€” | baseline RAG | -| `plain_rr` | โ€” | โ€” | + reranker | -| `graph` | โ€” | โ€” | + parent-paper expansion | -| `graph_concepts` | โ€” | โ€” | + MeSH concept hop | - -Paired McNemar tests (reranker effect, parent-expansion effect, concept-hop -effect) are written alongside the table. +Seeded random sample of **n = 200** PubMedQA `pqa_labeled` questions (seed 42, +identical across arms), `deepseek-r1:8b` via Ollama on an A100. Regenerate with +`scripts/compare.py` (writes `results/summary.md` and `results/ablation.png`). + +| Arm | Accuracy | Macro F1 | Avg latency | Adds | +| --- | --- | --- | --- | --- | +| `plain` | 30.0% | 29.7% | 6.4 s | baseline chunk RAG | +| `plain_rr` | 37.0% | 35.2% | 6.6 s | + cross-encoder reranker | +| **`graph`** | **59.5%** | **50.5%** | 7.5 s | + parent-paper expansion | +| `graph_concepts` | 57.5% | 50.0% | 40.8 s | + MeSH concept hop | + +**Paired McNemar tests** โ€” each contrast isolates one component on the same 200 questions: + +| Contrast | ฮ” accuracy | gains / losses | p | significant? | +| --- | --- | --- | --- | --- | +| `plain โ†’ plain_rr` (reranker) | +7.0 pp | 35 / 21 | 0.081 | no | +| `plain_rr โ†’ graph` (parent expansion) | **+22.5 pp** | 71 / 26 | **<0.0001** | **yes** | +| `graph โ†’ graph_concepts` (concept hop) | โˆ’2.0 pp | 26 / 30 | 0.69 | no | + +![4-arm ablation on PubMedQA](results/ablation.png) + +### What the ablation shows + +1. **The graph's decisive win is parent-document expansion** (+22.5 pp, + p < 0.0001). Retrieving at the fine-grained chunk level but feeding the LLM the + *full reconstructed abstract* (chunk โ†’ paper โ†’ all sections, via `HAS_CONTEXT`) + is what moves the needle โ€” for only ~1 s over `plain_rr`. With the label + leakage fixed, this is a clean, legitimate graph advantage. +2. **Single-fragment retrieval is not enough for PubMedQA.** `plain` and + `plain_rr` land *below* the majority-class baseline (PubMedQA is โ‰ˆ55% "yes"); a + lone ~250-character section rarely contains enough to judge the question. + Context sufficiency โ€” which the graph supplies โ€” is the dominant factor, and + `graph` is the only arm that clears the trivial baseline. +3. **The reranker helps modestly but not significantly** at this sample size + (+7 pp, p = 0.08). +4. **Concept-hop expansion does not help here** (โˆ’2 pp, p = 0.69) and costs ~5ร— + the latency. An honest โ€” and expected โ€” negative result: on single-abstract QA, + papers pulled in via shared MeSH terms act mostly as distractors. The graph + helps by *deepening* context (the full document), not by *broadening* it + (related documents). + +The macro-F1 / accuracy gap on the graph arms reflects weak recall on the rare +`maybe` class (~11% of the data) โ€” a dataset property, not a retrieval one. --- From 13897338d1a9eb9ea01067edb56d0c08b8d354b5 Mon Sep 17 00:00:00 2001 From: vardhjain Date: Fri, 12 Jun 2026 20:09:04 -0400 Subject: [PATCH 11/23] Add results artifacts (figure, summary); strip notebook run outputs - results/ablation.png + results/summary.md generated from the n=200 run - revert notebooks/02_benchmark.ipynb to the clean (output-free) wrapper that Colab's "Created using Colab" save had filled with execution outputs --- notebooks/02_benchmark.ipynb | 327 ++++++++++++++--------------------- results/ablation.png | Bin 0 -> 45837 bytes results/summary.md | 14 ++ 3 files changed, 143 insertions(+), 198 deletions(-) create mode 100644 results/ablation.png create mode 100644 results/summary.md diff --git a/notebooks/02_benchmark.ipynb b/notebooks/02_benchmark.ipynb index 01aba82..3569419 100644 --- a/notebooks/02_benchmark.ipynb +++ b/notebooks/02_benchmark.ipynb @@ -1,200 +1,131 @@ { - "cells": [ - { - "cell_type": "markdown", - "metadata": { - "id": "XZ4YK8X6grCa" - }, - "source": [ - "# 02 โ€” Benchmark: 4-arm GraphRAG vs PlainRAG ablation\n", - "\n", - "Thin Colab wrapper around `scripts/run_benchmark.py` and `scripts/compare.py`.\n", - "\n", - "**Colab Pro:** Runtime โ†’ Change runtime type โ†’ **A100 GPU** + **High-RAM**. This run is\n", - "LLM-inference-bound (~800 generations from a reasoning model), so the faster GPU is what\n", - "cuts wall-clock. Add `ARANGO_PASS` in the Colab **Secrets** panel. Run `01_ingest.ipynb` first.\n", - "\n", - "Arms (each isolates one component):\n", - "\n", - "| arm | adds |\n", - "| --- | --- |\n", - "| `plain` | vector top-k chunks (baseline) |\n", - "| `plain_rr` | + cross-encoder reranker |\n", - "| `graph` | + parent-paper expansion (full abstracts) |\n", - "| `graph_concepts` | + MeSH concept-hop expansion |\n", - "\n", - "The runner retries failed questions and auto-restarts Ollama if it crashes, and it\n", - "checkpoints every 25 questions โ€” so a transient 500 can't abort an arm." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "1mxPGKyQgrCe" - }, - "outputs": [], - "source": [ - "# Confirm the GPU. A100 is ideal; T4/L4 also work (slower).\n", - "!nvidia-smi --query-gpu=name,memory.total --format=csv" - ] - }, - { - "cell_type": "code", - "source": [ - "# A) Clean reset โ†’ one current copy (removes the doubled folder)\n", - "%cd /content\n", - "!rm -rf Knowledge_Graph_Question_Answering\n", - "!git clone -b revamp https://github.com/vardhjain/Knowledge_Graph_Question_Answering.git -q\n", - "%cd Knowledge_Graph_Question_Answering\n", - "!pip install -q -r requirements.txt" - ], - "metadata": { - "id": "0AVI3iC3g5YW", - "outputId": "639a2492-c5e3-4916-90fc-b7d57629ee7e", - "colab": { - "base_uri": "https://localhost:8080/" - } - }, - "execution_count": 15, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "/content\n", - "/content/Knowledge_Graph_Question_Answering\n" - ] - } - ] - }, - { - "cell_type": "code", - "source": [ - "# B) Env โ€” full context, bounded generation (corpus re-downloads once this run)\n", - "import os\n", - "from google.colab import userdata\n", - "os.environ['ARANGO_PASS'] = userdata.get('ARANGO_PASS')\n", - "os.environ['LLM_NUM_CTX'] = '8192'\n", - "os.environ['LLM_NUM_PREDICT'] = '1024'\n", - "print('ARANGO_PASS set:', bool(os.environ.get('ARANGO_PASS')))" - ], - "metadata": { - "id": "E40igHZRg53_", - "outputId": "c0c59bcf-94f6-4aa4-b748-7411f90494e6", - "colab": { - "base_uri": "https://localhost:8080/" - } - }, - "execution_count": 16, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "ARANGO_PASS set: True\n" - ] - } - ] - }, - { - "cell_type": "code", - "source": [ - "# C) Ollama + model (idempotent; fast if already pulled this session)\n", - "!which ollama || (apt-get install -y zstd -q && curl -fsSL https://ollama.com/install.sh | sh)\n", - "import subprocess, time\n", - "subprocess.Popen(['ollama','serve']); time.sleep(5)\n", - "!ollama pull deepseek-r1:8b" - ], - "metadata": { - "id": "ASx016R5g9U9", - "outputId": "cb334177-cbbb-473b-d66b-17cbb865cfff", - "colab": { - "base_uri": "https://localhost:8080/" - } - }, - "execution_count": 17, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "/usr/local/bin/ollama\n", - "\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\u001b[?2026h\u001b[?25l\u001b[1G\u001b[?25h\u001b[?2026l\n" - ] - } - ] - }, - { - "cell_type": "code", - "source": [ - "# D) Full run โ€” all four arms (~3 hrs on the 80GB A100)\n", - "for arm in ['plain', 'plain_rr', 'graph', 'graph_concepts']:\n", - " print(f'\\n===== {arm} =====')\n", - " !python scripts/run_benchmark.py --arm {arm} --n 200" - ], - "metadata": { - "id": "c50FRfHPg-vQ", - "outputId": "500d5a7b-91aa-4737-a956-c2941ff283b5", - "colab": { - "base_uri": "https://localhost:8080/" - } - }, - "execution_count": null, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "\n", - "===== plain =====\n", - "[Ollama] Ensuring server is healthy...\n", - "[ArangoDB] Connected.\n", - "[Corpus] Loading chunk store from ArangoDB (cached after first run)...\n" - ] - } - ] - }, - { - "cell_type": "code", - "source": [ - "# E) Aggregate โ†’ table, McNemar tests, figure\n", - "!python scripts/compare.py\n", - "from IPython.display import Image, display, Markdown\n", - "display(Markdown(open('results/summary.md').read()))\n", - "display(Image('results/ablation.png'))" - ], - "metadata": { - "id": "lxe8UUg0hANY" - }, - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "code", - "source": [], - "metadata": { - "id": "eCMJwqcfhBll" - }, - "execution_count": null, - "outputs": [] - } - ], - "metadata": { - "accelerator": "GPU", - "colab": { - "provenance": [], - "gpuType": "A100", - "machine_shape": "hm" - }, - "kernelspec": { - "display_name": "Python 3", - "name": "python3" - }, - "language_info": { - "name": "python" - } + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 02 โ€” Benchmark: 4-arm GraphRAG vs PlainRAG ablation\n", + "\n", + "Thin Colab wrapper around `scripts/run_benchmark.py` and `scripts/compare.py`.\n", + "\n", + "**Colab Pro:** Runtime โ†’ Change runtime type โ†’ **A100 GPU** + **High-RAM**. This run is\n", + "LLM-inference-bound (~800 generations from a reasoning model), so the faster GPU is what\n", + "cuts wall-clock. Add `ARANGO_PASS` in the Colab **Secrets** panel. Run `01_ingest.ipynb` first.\n", + "\n", + "Arms (each isolates one component):\n", + "\n", + "| arm | adds |\n", + "| --- | --- |\n", + "| `plain` | vector top-k chunks (baseline) |\n", + "| `plain_rr` | + cross-encoder reranker |\n", + "| `graph` | + parent-paper expansion (full abstracts) |\n", + "| `graph_concepts` | + MeSH concept-hop expansion |\n", + "\n", + "The runner retries failed questions and auto-restarts Ollama if it crashes, and it\n", + "checkpoints every 25 questions โ€” so a transient 500 can't abort an arm." + ] }, - "nbformat": 4, - "nbformat_minor": 0 -} \ No newline at end of file + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Confirm the GPU. A100 is ideal; T4/L4 also work (slower).\n", + "!nvidia-smi --query-gpu=name,memory.total --format=csv" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Reset-safe clone: always starts from /content and removes any prior copy,\n", + "# so re-running this cell can never nest a second checkout.\n", + "%cd /content\n", + "!rm -rf Knowledge_Graph_Question_Answering\n", + "!git clone -b revamp https://github.com/vardhjain/Knowledge_Graph_Question_Answering.git -q\n", + "%cd Knowledge_Graph_Question_Answering\n", + "!pip install -q -r requirements.txt" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Install Ollama and pull the LLM (once). The benchmark script manages the\n", + "# server from here on (health-check + auto-restart).\n", + "!which ollama || (apt-get install -y zstd -q && curl -fsSL https://ollama.com/install.sh | sh)\n", + "import subprocess, time\n", + "subprocess.Popen(['ollama', 'serve'])\n", + "time.sleep(5)\n", + "!ollama pull deepseek-r1:8b" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "from google.colab import userdata\n", + "os.environ['ARANGO_PASS'] = userdata.get('ARANGO_PASS')\n", + "# 80GB A100: full context, generation bounded for speed (does not affect the\n", + "# comparison โ€” every arm shares these). Lower NUM_CTX to 4096 on a small GPU.\n", + "os.environ['LLM_NUM_CTX'] = '8192'\n", + "os.environ['LLM_NUM_PREDICT'] = '1024'\n", + "print('ARANGO_PASS set:', bool(os.environ.get('ARANGO_PASS')))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Run all four arms. The chunk corpus is downloaded once and cached, then\n", + "# reused by every arm (identical corpus -> fair comparison). Each arm saves its\n", + "# own results JSON, so if one dies you can re-run just that arm.\n", + "for arm in ['plain', 'plain_rr', 'graph', 'graph_concepts']:\n", + " print(f'\\n===== {arm} =====')\n", + " !python scripts/run_benchmark.py --arm {arm} --n 200" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Summary table, paired McNemar tests, and the ablation figure.\n", + "!python scripts/compare.py\n", + "from IPython.display import Image, display, Markdown\n", + "display(Markdown(open('results/summary.md').read()))\n", + "display(Image('results/ablation.png'))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Optional: commit results back to GitHub (set a PAT first).\n", + "# !git config user.email you@example.com && git config user.name you\n", + "# !git add results/ && git commit -m 'Add benchmark results' && git push" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": {"provenance": [], "gpuType": "A100", "machine_shape": "hm"}, + "kernelspec": {"display_name": "Python 3", "name": "python3"}, + "language_info": {"name": "python"} + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/results/ablation.png b/results/ablation.png new file mode 100644 index 0000000000000000000000000000000000000000..a09a649392f475b13f8215f87e0046bf364d5264 GIT binary patch literal 45837 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 P(=lV}9OEmKEqDGeo7hk^ literal 0 HcmV?d00001 diff --git a/results/summary.md b/results/summary.md new file mode 100644 index 0000000..8f41522 --- /dev/null +++ b/results/summary.md @@ -0,0 +1,14 @@ +| Arm | Accuracy | Macro F1 | Avg latency (s) | n | +| --- | --- | --- | --- | --- | +| plain | 30.00% | 29.69% | 6.4 | 200 | +| plain_rr | 37.00% | 35.21% | 6.6 | 200 | +| graph | 59.50% | 50.51% | 7.5 | 200 | +| graph_concepts | 57.50% | 49.97% | 40.8 | 200 | + +### Significance (paired McNemar) + +| Contrast | ฮ”acc (pp) | gains | losses | p | sig? | +| --- | --- | --- | --- | --- | --- | +| plain โ†’ plain_rr (reranker effect) | +7.00 | 35 | 21 | 0.0814 | no | +| plain_rr โ†’ graph (parent-expansion effect) | +22.50 | 71 | 26 | 0.0000 | yes | +| graph โ†’ graph_concepts (concept-hop effect) | -2.00 | 26 | 30 | 0.6889 | no | From 1bfc4df9409e9cb9dedb9314a1f816b6b72ee66d Mon Sep 17 00:00:00 2001 From: vardhjain Date: Fri, 12 Jun 2026 20:23:06 -0400 Subject: [PATCH 12/23] Add community health files, Makefile, diagram, and repo metadata Bring the repo up to industry/community standards for a public release: - Makefile (install/test/lint/ingest/benchmark/compare/clean) + `make help` - CONTRIBUTING, CODE_OF_CONDUCT, SECURITY, CHANGELOG, CITATION.cff - .github/ ISSUE_TEMPLATE (bug, feature, config) + PULL_REQUEST_TEMPLATE - assets/architecture.svg + README badges and embedded diagram - .pre-commit-config.yaml (ruff + hygiene hooks), .editorconfig - .gitattributes to enforce LF (Makefile-safe) and tidy linguist stats - richer pyproject metadata (authors, urls, classifiers, keywords, dev extras) Deliberately no configs/ dir: configuration lives in src/kgqa/config.py (typed + env-overridable), which is documented in CONTRIBUTING. --- .editorconfig | 20 ++++++ .gitattributes | 18 +++++ .github/ISSUE_TEMPLATE/bug_report.md | 34 ++++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 ++ .github/ISSUE_TEMPLATE/feature_request.md | 24 +++++++ .github/PULL_REQUEST_TEMPLATE.md | 29 ++++++++ .pre-commit-config.yaml | 21 ++++++ CHANGELOG.md | 45 +++++++++++++ CITATION.cff | 25 +++++++ CODE_OF_CONDUCT.md | 57 ++++++++++++++++ CONTRIBUTING.md | 64 ++++++++++++++++++ Makefile | 38 +++++++++++ README.md | 28 +++++++- SECURITY.md | 23 +++++++ assets/architecture.svg | 81 +++++++++++++++++++++++ pyproject.toml | 18 +++++ 16 files changed, 527 insertions(+), 3 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .pre-commit-config.yaml create mode 100644 CHANGELOG.md create mode 100644 CITATION.cff create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Makefile create mode 100644 SECURITY.md create mode 100644 assets/architecture.svg diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7bc8663 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# https://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{md,yml,yaml,json,toml}] +indent_size = 2 + +[*.ipynb] +trim_trailing_whitespace = false +insert_final_newline = false + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..04e2728 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,18 @@ +# Normalize line endings: LF in the repository and on checkout, everywhere. +* text=auto eol=lf + +# Must be LF to run on Unix (Makefile is also tab-sensitive). +Makefile text eol=lf +*.sh text eol=lf + +# Binary assets โ€” no EOL conversion, no diff noise. +*.png binary +*.jpg binary +*.pdf binary +*.pptx binary +*.pkl binary +*.bin binary + +# Thin Colab wrappers are documentation, not core source โ€” keep them out of the +# language breakdown so the repo reads as the Python project it is. +*.ipynb linguist-documentation diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..daa70c3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +name: Bug report +about: Report something that isn't working as expected +title: "[Bug] " +labels: bug +assignees: "" +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To reproduce** +Steps or the exact command, e.g.: +```bash +python scripts/run_benchmark.py --arm graph --n 200 +``` + +**Expected behavior** +What you expected to happen. + +**Logs / traceback** +``` +paste the error here +``` + +**Environment** +- OS: +- Python version: +- Running where: [local / Colab] +- GPU (if any): +- Arango reachable / Ollama running: [yes/no] + +**Additional context** +Anything else that might help. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a78cf63 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Question / discussion + url: https://github.com/vardhjain/Knowledge_Graph_Question_Answering/discussions + about: Ask a question or discuss the methodology, results, or design. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..7392076 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +name: Feature request +about: Suggest an idea or improvement +title: "[Feature] " +labels: enhancement +assignees: "" +--- + +**What problem does this solve?** +A clear description of the motivation or gap. + +**Proposed solution** +What you'd like to happen. + +**Fairness check (for retrieval/eval changes)** +This project is a *fair* ablation. If your idea touches retrieval or evaluation, +note how it keeps the arms comparable (shared corpus/embedder/reranker/prompt/ +LLM/top-k) and avoids leaking the answer into context. + +**Alternatives considered** +Any other approaches you weighed. + +**Additional context** +Links, papers, or examples. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..286fc76 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,29 @@ +## Summary + + + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Refactor / cleanup +- [ ] Docs +- [ ] Benchmark / results + +## Checklist + +- [ ] `make test` passes +- [ ] `make lint` passes +- [ ] `CHANGELOG.md` updated under "Unreleased" +- [ ] Docs/README updated if behavior changed + +## Fairness (retrieval/evaluation changes only) + +- [ ] Confounders (embedder, reranker, prompt, LLM, top-k, seed, n) stay in + `config.py` and identical across arms +- [ ] No benchmark question/answer can leak into a retrieved context + (the leakage regression test still passes) + +## Notes + + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e10fe55 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +# Run automatically on `git commit` after `pre-commit install`. +# See https://pre-commit.com +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-yaml + - id: check-toml + - id: check-added-large-files + args: [--maxkb=1024] + - id: check-merge-conflict + - id: detect-private-key diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..49fe671 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.0.0] โ€” 2026-06-12 + +The "fair comparison" revamp: turned a confounded notebook demo into a +controlled, reproducible 4-arm ablation with an industry-standard repo layout. + +### Added +- Importable `src/kgqa/` package: `config`, `prompts`, `llm`, `data`, + `evaluation`, `models`, and a `retrieval/` sub-package (`base`, `plain`, `graph`). +- Four retrieval arms isolating each component: + `plain โ†’ plain_rr โ†’ graph โ†’ graph_concepts`. +- A shared `ChunkStore` so every arm searches an identical corpus. +- MeSH concept-hop expansion (`graph_concepts`) โ€” the previously unused + `Concepts`/`MENTIONS` graph is now exercised. +- Seeded random sampling and a paired **McNemar** significance test. +- `scripts/`: `ingest.py` (leakage-free graph build), `run_benchmark.py` + (`--arm`, retry + Ollama auto-restart + checkpointing), `compare.py`. +- Test suite (CPU-only via fakes), GitHub Actions CI, `ruff` + `pre-commit`. +- Docs and meta: README with results, `CONTRIBUTING`, `CODE_OF_CONDUCT`, + `SECURITY`, `CITATION.cff`, issue/PR templates, `Makefile`, architecture diagram. +- Benchmark results (n=200) and ablation figure under `results/`. + +### Fixed +- **Label leakage:** ingestion no longer stores a question-derived `title` or + `final_decision`; graph contexts use generic `=== STUDY n ===` labels, so the + benchmark question/answer can never appear in a retrieved context. +- **Confounded comparison:** the cross-encoder reranker is now its own arm + instead of a hidden advantage for GraphRAG. +- **Inconsistent corpus/chunking** across arms โ€” now identical. +- `NameError` in the graph-expansion fallback path. + +### Changed +- Generation is bounded (`num_predict`) and the model kept resident + (`keep_alive`); `LLM_NUM_CTX` / `LLM_NUM_PREDICT` are environment-tunable. +- Removed the dead `faiss` dependency (PlainRAG uses the shared numpy-cosine store). + +[Unreleased]: https://github.com/vardhjain/Knowledge_Graph_Question_Answering/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/vardhjain/Knowledge_Graph_Question_Answering/releases/tag/v1.0.0 diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..b5c28ea --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,25 @@ +cff-version: 1.2.0 +title: "Knowledge Graph Question Answering: a fair GraphRAG vs PlainRAG comparison" +message: "If you use this software or its findings, please cite it as below." +type: software +authors: + - given-names: Vardh + family-names: Jain + email: vardhjain20@gmail.com +repository-code: "https://github.com/vardhjain/Knowledge_Graph_Question_Answering" +abstract: >- + A controlled 4-arm ablation (plain, plain_rr, graph, graph_concepts) on + PubMedQA that isolates what a knowledge graph contributes to retrieval-augmented + question answering, holding corpus, chunking, embedder, reranker, prompt, LLM, + and top-k constant. Includes a paired McNemar significance test and a + leakage-free ArangoDB graph schema. +keywords: + - graphrag + - retrieval-augmented-generation + - knowledge-graph + - pubmedqa + - arangodb + - ablation-study +license: MIT +version: 1.0.0 +date-released: "2026-06-12" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..4535721 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,57 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes +- Focusing on what is best for the overall community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards and +will take appropriate and fair corrective action in response to any behavior +they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement via GitHub. All +complaints will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..edf5a5c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,64 @@ +# Contributing + +Thanks for your interest in this project! It's a research codebase for a *fair* +GraphRAG vs PlainRAG comparison on PubMedQA, so contributions that improve +rigor, reproducibility, or clarity are especially welcome. + +## Development setup + +```bash +git clone https://github.com/vardhjain/Knowledge_Graph_Question_Answering.git +cd Knowledge_Graph_Question_Answering +python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate +make install-dev # or: pip install -r requirements-dev.txt +pre-commit install # optional: run ruff automatically on commit +``` + +The unit tests inject fakes for the encoder, reranker, and ArangoDB, so you can +run the whole suite on CPU with **no GPU, Ollama, or database** required: + +```bash +make test # pytest +make lint # ruff +``` + +See the [Makefile](Makefile) (`make help`) for all shortcuts. + +## Where things live + +| Path | What | +| --- | --- | +| `src/kgqa/` | the importable package (single source of truth) | +| `src/kgqa/config.py` | **all** shared constants + env overrides | +| `src/kgqa/retrieval/` | the four retrieval arms (`base`, `plain`, `graph`) | +| `scripts/` | `ingest.py`, `run_benchmark.py`, `compare.py` | +| `notebooks/` | thin Colab wrappers (kept output-free) | +| `tests/` | pytest suite (CPU-only via fakes) | + +> **Why no `configs/` directory?** Configuration is centralized in +> `src/kgqa/config.py` as a typed dataclass with environment-variable overrides +> (and an `.env.example` template). For this project that's safer and less +> error-prone than scattering YAML/JSON config files; please keep new knobs there. + +## Ground rules for changes + +This repo's whole point is a **fair** comparison. Before changing retrieval or +evaluation, please make sure: + +- Anything that could confound the arms (embedder, reranker, prompt, LLM, top-k, + seed, sample size) stays in `config.py` and identical across arms. +- No benchmark answer or question text can leak into a retrieved context + (there's a regression test for this โ€” keep it green). +- New behavior has a test; `make test` and `make lint` both pass. + +## Pull requests + +1. Branch from `main`, make focused commits. +2. Run `make test && make lint`. +3. Open a PR using the template; describe what changed and why, and update + `CHANGELOG.md` under "Unreleased". + +## Commit messages + +Short imperative subject line, a blank line, then a body explaining the *why* +when it isn't obvious. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c994e96 --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +.DEFAULT_GOAL := help +.PHONY: help install install-dev test lint format ingest benchmark compare clean + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' + +install: ## Install runtime dependencies + pip install -r requirements.txt + +install-dev: ## Install dev dependencies (tests + lint) + pip install -r requirements-dev.txt + +test: ## Run the test suite + pytest + +lint: ## Lint with ruff + ruff check src scripts tests + +format: ## Auto-fix lint issues with ruff + ruff check --fix src scripts tests + +ingest: ## Build the ArangoDB knowledge graph (needs ARANGO_PASS) + python scripts/ingest.py + +benchmark: ## Run all four arms (needs ARANGO_PASS + Ollama) + @for arm in plain plain_rr graph graph_concepts; do \ + echo "===== $$arm ====="; \ + python scripts/run_benchmark.py --arm $$arm --n 200; \ + done + +compare: ## Aggregate results into table, McNemar tests, and figure + python scripts/compare.py + +clean: ## Remove caches and generated vector cache + rm -rf .pytest_cache .ruff_cache *.egg-info src/*.egg-info \ + pubmed_vectors_cache.pkl + find . -type d -name __pycache__ -exec rm -rf {} + diff --git a/README.md b/README.md index 738fe76..ed83fda 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Knowledge Graph Question Answering โ€” GraphRAG vs PlainRAG, done fairly +[![CI](https://github.com/vardhjain/Knowledge_Graph_Question_Answering/actions/workflows/ci.yml/badge.svg)](https://github.com/vardhjain/Knowledge_Graph_Question_Answering/actions/workflows/ci.yml) +[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) +[![Lint: ruff](https://img.shields.io/badge/lint-ruff-261230.svg)](https://github.com/astral-sh/ruff) + A controlled study of **what a knowledge graph actually contributes** to retrieval-augmented question answering on biomedical literature ([PubMedQA](https://pubmedqa.github.io/)). @@ -21,6 +26,8 @@ same seeded sample, same top-k. The accuracy delta between adjacent arms is attributable to exactly one component, and we report a **paired McNemar test** so you can tell a real effect from noise. +![Architecture and 4-arm ablation](assets/architecture.svg) + --- ## Why the original comparison was unfair (and what changed) @@ -167,13 +174,28 @@ The macro-F1 / accuracy gap on the graph arms reflects weak recall on the rare ## Development ```bash -pytest # 17 tests, all CPU, no external services -ruff check src scripts tests +make install-dev # deps for tests + lint +make test # pytest โ€” 17 tests, all CPU, no external services +make lint # ruff +make help # all shortcuts (ingest, benchmark, compare, ...) ``` CI runs ruff + pytest on every push/PR (Python 3.10 and 3.11). Unit tests inject fakes for the encoder, reranker, and ArangoDB, so the heavy ML dependencies are -never needed just to verify the logic. +never needed just to verify the logic. Optionally `pre-commit install` to run +ruff automatically on each commit. + +## Contributing + +Contributions are welcome โ€” see [CONTRIBUTING.md](CONTRIBUTING.md) for setup, the +project layout, and the fairness ground rules. Changes are tracked in +[CHANGELOG.md](CHANGELOG.md); please be kind and follow the +[Code of Conduct](CODE_OF_CONDUCT.md). + +## Citing + +If this project or its findings are useful in your work, please cite it โ€” see +[CITATION.cff](CITATION.cff) (GitHub renders a "Cite this repository" button). ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..714ea7d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +This is a research project, but a few things are worth handling carefully. + +## Reporting a vulnerability + +If you find a security issue (for example, an accidental credential commit or a +dependency vulnerability), please **do not open a public issue**. Instead, use +GitHub's [private vulnerability reporting](https://github.com/vardhjain/Knowledge_Graph_Question_Answering/security/advisories/new) +or email the maintainer. You can expect an acknowledgement within a few days. + +## Secrets + +- Never commit real credentials. ArangoDB and LLM settings are read from the + environment (or a local `.env`, which is git-ignored). Use `.env.example` as a + template, and Colab **Secrets** for notebook runs. +- If a secret is ever committed, rotate it immediately โ€” removing it from the + latest commit is not enough, as it remains in git history. + +## Supported versions + +The latest release on `main` is supported. This project pins minimum dependency +versions in `requirements.txt`; run `pip list --outdated` periodically. diff --git a/assets/architecture.svg b/assets/architecture.svg new file mode 100644 index 0000000..6424cd3 --- /dev/null +++ b/assets/architecture.svg @@ -0,0 +1,81 @@ + + + + + + + + + + Knowledge Graph QA โ€” fair 4-arm ablation + Every layer is held constant; only the retrieval strategy changes. + + + + + PubMedQA question + + + Encode ยท all-MiniLM-L6-v2 + + + Vector search ยท ChunkStore + 206,613 chunks ยท ArangoDB + + + Cross-encoder rerank + rerank arms only + + + Context assembly + โ† the only thing that differs + + + LLM ยท deepseek-r1:8b (Ollama) + + + Extract yes/no/maybe โ†’ McNemar + + + + + + + + + + + + + + + + + + The four arms (accuracy, n=200) + + + + + + plain + raw top-k chunks + 30.0% + + plain_rr + + cross-encoder reranker + 37.0% + + graph โ˜… + + parent abstracts (HAS_CONTEXT) + 59.5% + + graph_concepts + + concept hop (MENTIONS) + 57.5% + + + + Parent-document expansion: +22.5 pp over plain_rr + paired McNemar p < 0.0001 ยท concept hop did not help (โˆ’2 pp, n.s.) + diff --git a/pyproject.toml b/pyproject.toml index 351d810..3013273 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,24 @@ description = "Fair GraphRAG vs PlainRAG comparison on PubMedQA" readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } +authors = [{ name = "Vardh Jain", email = "vardhjain20@gmail.com" }] +keywords = ["graphrag", "rag", "knowledge-graph", "pubmedqa", "arangodb", "llm", "ablation"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] + +[project.urls] +Homepage = "https://github.com/vardhjain/Knowledge_Graph_Question_Answering" +Repository = "https://github.com/vardhjain/Knowledge_Graph_Question_Answering" +Issues = "https://github.com/vardhjain/Knowledge_Graph_Question_Answering/issues" + +[project.optional-dependencies] +dev = ["pytest>=8.0", "ruff>=0.4.0", "pre-commit>=3.5"] [tool.setuptools.packages.find] where = ["src"] From 4861a3cddfb1fd82af27948581cb3392355c9a1f Mon Sep 17 00:00:00 2001 From: vardhjain Date: Fri, 12 Jun 2026 20:56:30 -0400 Subject: [PATCH 13/23] Make the project portable: no hardcoded endpoint, local-or-cloud setup So anyone can run it out of the box: - default ARANGO_HOST is now http://localhost:8529 (no specific deployment baked in) - add docker-compose.yml for a one-command local ArangoDB - .env.example and README document both paths (local Docker / cloud Oasis) - notebooks read ARANGO_HOST + ARANGO_PASS from Colab Secrets (nothing hardcoded) and clone the main branch - README setup/run instructions generalized --- .env.example | 8 ++++-- README.md | 48 +++++++++++++++++++++--------------- docker-compose.yml | 22 +++++++++++++++++ notebooks/01_ingest.ipynb | 28 ++++++++++++++------- notebooks/02_benchmark.ipynb | 30 ++++++++++++++-------- src/kgqa/config.py | 2 +- 6 files changed, 96 insertions(+), 42 deletions(-) create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example index 6a5ce5f..fff872b 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,12 @@ # Copy to .env and fill in. Never commit .env (it is in .gitignore). # On Google Colab, set these via the Secrets panel (key icon) instead. -# โ”€โ”€ ArangoDB Oasis (GraphRAG only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -ARANGO_HOST=https://581c546a8d66.arangodb.cloud:8529 +# โ”€โ”€ ArangoDB (GraphRAG only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Local (default): use the bundled docker-compose โ€” `docker compose up -d`, +# then ARANGO_HOST=http://localhost:8529 and ARANGO_PASS=devpassword. +# Cloud (ArangoDB Oasis): point ARANGO_HOST at your deployment endpoint, e.g. +# https://.arangodb.cloud:8529 +ARANGO_HOST=http://localhost:8529 ARANGO_USER=root ARANGO_PASS= ARANGO_DB=pubmed_graph diff --git a/README.md b/README.md index ed83fda..58b92f6 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ docs/ project report (PDF) and slides (PPTX) - **Dataset:** PubMedQA (`pqa_labeled` for evaluation, `pqa_unlabeled` for corpus) - **Embeddings:** `all-MiniLM-L6-v2` (384-dim) - **Reranker:** `cross-encoder/ms-marco-MiniLM-L-6-v2` -- **Graph DB:** ArangoDB Oasis (Papers / Chunks / Concepts; HAS_CONTEXT / MENTIONS) +- **Graph DB:** ArangoDB โ€” any instance (local Docker or [ArangoDB Oasis](https://cloud.arangodb.com)); schema: Papers / Chunks / Concepts; HAS_CONTEXT / MENTIONS - **LLM:** `deepseek-r1:8b` via [Ollama](https://ollama.com) --- @@ -92,34 +92,42 @@ docs/ project report (PDF) and slides (PPTX) ```bash pip install -r requirements.txt # add -r requirements-dev.txt for tests -cp .env.example .env # then fill in ARANGO_PASS +cp .env.example .env # then set ARANGO_PASS (and ARANGO_HOST if remote) ``` -Secrets are read from the environment (or a local `.env`, or Colab Secrets): -`ARANGO_HOST`, `ARANGO_USER`, `ARANGO_PASS`, `ARANGO_DB`. Nothing is hardcoded. +All connection settings are read from the environment (or a local `.env`, or +Colab Secrets) โ€” `ARANGO_HOST`, `ARANGO_USER`, `ARANGO_PASS`, `ARANGO_DB`. +**Nothing is hardcoded**; the default host is `http://localhost:8529`. -## Running the benchmark - -The graph lives in ArangoDB Oasis and the LLM runs on a GPU, so the benchmark is -designed to run on **Google Colab (T4)** โ€” open the notebooks below. To run -locally you need a reachable ArangoDB and a running Ollama. +You need two services: an **ArangoDB** instance and a running **Ollama**. ```bash -# 1. Build the graph once -python scripts/ingest.py +# ArangoDB โ€” option A: local, via the bundled compose file +docker compose up -d # ArangoDB at localhost:8529 (root / devpassword) +export ARANGO_PASS=devpassword # PowerShell: $env:ARANGO_PASS="devpassword" -# 2. Run each arm (downloads + caches the shared chunk corpus on first run) -python scripts/run_benchmark.py --arm plain --n 200 -python scripts/run_benchmark.py --arm plain_rr --n 200 -python scripts/run_benchmark.py --arm graph --n 200 -python scripts/run_benchmark.py --arm graph_concepts --n 200 +# ArangoDB โ€” option B: a cloud deployment (e.g. ArangoDB Oasis free tier) +# export ARANGO_HOST=https://.arangodb.cloud:8529 +# export ARANGO_PASS= + +# Ollama (LLM) +ollama serve & ollama pull deepseek-r1:8b +``` -# 3. Summary table, McNemar tests, ablation figure -> results/ -python scripts/compare.py +## Running the benchmark + +```bash +python scripts/ingest.py # build the graph once +make benchmark # all four arms (n=200) +# or run arms individually: +# python scripts/run_benchmark.py --arm plain --n 200 (plain_rr / graph / graph_concepts) +python scripts/compare.py # table + McNemar + figure -> results/ ``` -On Colab, run [`notebooks/01_ingest.ipynb`](notebooks/01_ingest.ipynb) once, then -[`notebooks/02_benchmark.ipynb`](notebooks/02_benchmark.ipynb). +The benchmark is LLM-bound and benefits from a GPU. If you don't have one, +**Google Colab** works well: run [`notebooks/01_ingest.ipynb`](notebooks/01_ingest.ipynb) +once, then [`notebooks/02_benchmark.ipynb`](notebooks/02_benchmark.ipynb) (set +`ARANGO_HOST` / `ARANGO_PASS` in Colab Secrets). --- diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2cf966a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +# Local ArangoDB for development and running the benchmark without a cloud account. +# +# docker compose up -d +# export ARANGO_PASS=devpassword # PowerShell: $env:ARANGO_PASS="devpassword" +# python scripts/ingest.py # then run_benchmark.py / compare.py +# +# Web UI: http://localhost:8529 (user: root, password: devpassword) +# Change the password below (and ARANGO_PASS) before exposing this anywhere. + +services: + arangodb: + image: arangodb:3.11 + container_name: kgqa-arangodb + environment: + ARANGO_ROOT_PASSWORD: devpassword + ports: + - "8529:8529" + volumes: + - arango_data:/var/lib/arangodb3 + +volumes: + arango_data: diff --git a/notebooks/01_ingest.ipynb b/notebooks/01_ingest.ipynb index 583b5eb..8bd0838 100644 --- a/notebooks/01_ingest.ipynb +++ b/notebooks/01_ingest.ipynb @@ -9,11 +9,14 @@ "Thin Colab wrapper around `scripts/ingest.py`. Builds the **leakage-free** knowledge graph\n", "(Papers / Chunks / Concepts + HAS_CONTEXT / MENTIONS edges).\n", "\n", - "**Colab Pro:** a **GPU** runtime (A100/L4) + **High-RAM** speeds the embedding pass.\n", - "Add `ARANGO_PASS` in the Colab **Secrets** panel (key icon, left sidebar).\n", + "**Before running**, add these to the Colab **Secrets** panel (key icon, left sidebar):\n", + "- `ARANGO_PASS` โ€” your ArangoDB password (required)\n", + "- `ARANGO_HOST` โ€” your endpoint, e.g. `https://.arangodb.cloud:8529`\n", + " (ArangoDB Oasis offers a free tier; or run any reachable ArangoDB)\n", "\n", - "Run this notebook **once** against the (empty) deployment, then use `02_benchmark.ipynb`.\n", - "Ingesting labeled + unlabeled (~62k papers) is mostly network-bound on the inserts." + "A **GPU** runtime (+ High-RAM) speeds the embedding pass. Run this notebook **once**,\n", + "then use `02_benchmark.ipynb`. Ingesting labeled + unlabeled (~62k papers) is mostly\n", + "network-bound on the inserts." ] }, { @@ -26,7 +29,7 @@ "# so re-running this cell can never nest a second checkout.\n", "%cd /content\n", "!rm -rf Knowledge_Graph_Question_Answering\n", - "!git clone -b revamp https://github.com/vardhjain/Knowledge_Graph_Question_Answering.git -q\n", + "!git clone -b main https://github.com/vardhjain/Knowledge_Graph_Question_Answering.git -q\n", "%cd Knowledge_Graph_Question_Answering\n", "!pip install -q -r requirements.txt" ] @@ -40,10 +43,17 @@ "import os\n", "from google.colab import userdata\n", "\n", - "os.environ['ARANGO_PASS'] = userdata.get('ARANGO_PASS')\n", - "# Endpoint is baked into kgqa.config; override here only if your deployment differs:\n", - "# os.environ['ARANGO_HOST'] = 'https://581c546a8d66.arangodb.cloud:8529'\n", - "print('ARANGO_PASS set:', bool(os.environ.get('ARANGO_PASS')))" + "# Pull connection settings from Colab Secrets (nothing is hardcoded).\n", + "for key in ['ARANGO_PASS', 'ARANGO_HOST', 'ARANGO_DB']:\n", + " try:\n", + " val = userdata.get(key)\n", + " if val:\n", + " os.environ[key] = val\n", + " except Exception:\n", + " pass\n", + "\n", + "assert os.environ.get('ARANGO_PASS'), 'Add ARANGO_PASS in the Secrets panel.'\n", + "print('ARANGO_HOST:', os.environ.get('ARANGO_HOST', '(default http://localhost:8529)'))" ] }, { diff --git a/notebooks/02_benchmark.ipynb b/notebooks/02_benchmark.ipynb index 3569419..4ad293a 100644 --- a/notebooks/02_benchmark.ipynb +++ b/notebooks/02_benchmark.ipynb @@ -8,9 +8,9 @@ "\n", "Thin Colab wrapper around `scripts/run_benchmark.py` and `scripts/compare.py`.\n", "\n", - "**Colab Pro:** Runtime โ†’ Change runtime type โ†’ **A100 GPU** + **High-RAM**. This run is\n", - "LLM-inference-bound (~800 generations from a reasoning model), so the faster GPU is what\n", - "cuts wall-clock. Add `ARANGO_PASS` in the Colab **Secrets** panel. Run `01_ingest.ipynb` first.\n", + "**Use a GPU runtime** (a faster GPU mainly cuts wall-clock since this is\n", + "LLM-inference-bound). Add `ARANGO_PASS` and `ARANGO_HOST` in the Colab **Secrets**\n", + "panel. Run `01_ingest.ipynb` first.\n", "\n", "Arms (each isolates one component):\n", "\n", @@ -22,7 +22,7 @@ "| `graph_concepts` | + MeSH concept-hop expansion |\n", "\n", "The runner retries failed questions and auto-restarts Ollama if it crashes, and it\n", - "checkpoints every 25 questions โ€” so a transient 500 can't abort an arm." + "checkpoints every 25 questions โ€” so a transient error can't abort an arm." ] }, { @@ -31,7 +31,7 @@ "metadata": {}, "outputs": [], "source": [ - "# Confirm the GPU. A100 is ideal; T4/L4 also work (slower).\n", + "# Confirm the GPU (optional).\n", "!nvidia-smi --query-gpu=name,memory.total --format=csv" ] }, @@ -45,7 +45,7 @@ "# so re-running this cell can never nest a second checkout.\n", "%cd /content\n", "!rm -rf Knowledge_Graph_Question_Answering\n", - "!git clone -b revamp https://github.com/vardhjain/Knowledge_Graph_Question_Answering.git -q\n", + "!git clone -b main https://github.com/vardhjain/Knowledge_Graph_Question_Answering.git -q\n", "%cd Knowledge_Graph_Question_Answering\n", "!pip install -q -r requirements.txt" ] @@ -73,12 +73,22 @@ "source": [ "import os\n", "from google.colab import userdata\n", - "os.environ['ARANGO_PASS'] = userdata.get('ARANGO_PASS')\n", - "# 80GB A100: full context, generation bounded for speed (does not affect the\n", - "# comparison โ€” every arm shares these). Lower NUM_CTX to 4096 on a small GPU.\n", + "\n", + "# Connection settings from Colab Secrets (nothing hardcoded).\n", + "for key in ['ARANGO_PASS', 'ARANGO_HOST', 'ARANGO_DB']:\n", + " try:\n", + " val = userdata.get(key)\n", + " if val:\n", + " os.environ[key] = val\n", + " except Exception:\n", + " pass\n", + "assert os.environ.get('ARANGO_PASS'), 'Add ARANGO_PASS in the Secrets panel.'\n", + "\n", + "# Generation knobs (identical across arms, so the comparison is unaffected).\n", + "# Raise NUM_CTX on a large-VRAM GPU; lower it on a small one if you hit OOM.\n", "os.environ['LLM_NUM_CTX'] = '8192'\n", "os.environ['LLM_NUM_PREDICT'] = '1024'\n", - "print('ARANGO_PASS set:', bool(os.environ.get('ARANGO_PASS')))" + "print('ARANGO_HOST:', os.environ.get('ARANGO_HOST', '(default http://localhost:8529)'))" ] }, { diff --git a/src/kgqa/config.py b/src/kgqa/config.py index d3ce7c1..fa14d3f 100644 --- a/src/kgqa/config.py +++ b/src/kgqa/config.py @@ -60,7 +60,7 @@ class ArangoConfig: """ArangoDB Oasis connection settings, read from the environment.""" host: str = field(default_factory=lambda: os.environ.get( - "ARANGO_HOST", "https://581c546a8d66.arangodb.cloud:8529")) + "ARANGO_HOST", "http://localhost:8529")) user: str = field(default_factory=lambda: os.environ.get("ARANGO_USER", "root")) password: str = field(default_factory=lambda: os.environ.get("ARANGO_PASS", "")) db_name: str = field(default_factory=lambda: os.environ.get("ARANGO_DB", "pubmed_graph")) From a4c327130afa932658b0d2a5ba3936ecc6454596 Mon Sep 17 00:00:00 2001 From: vardhjain Date: Sat, 13 Jun 2026 12:23:41 -0400 Subject: [PATCH 14/23] Add interactive UIs: Gradio chat demo + Streamlit results dashboard - app/chat_app.py: live GraphRAG chat over the winning `graph` arm; answers cite source PubMed IDs (--share for a public link, --concepts for the concept arm) - app/dashboard.py: Streamlit dashboard of the ablation (bars, McNemar, per-class confusion matrices when raw results are present); reads results/ only, so it deploys to Streamlit Cloud - BaseRetriever.chat(): conversational answer + retrieved source pubids (tested) - compare.py now also emits results/summary.json (structured metrics) for the dashboard - requirements-app.txt, make chat/dashboard/install-app, app/ added to ruff + CI - README "Interactive demo & dashboard" section + app/README deployment notes --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 9 +++ Makefile | 15 ++++- README.md | 21 +++++++ app/README.md | 33 ++++++++++ app/chat_app.py | 84 +++++++++++++++++++++++++ app/dashboard.py | 126 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 3 +- requirements-app.txt | 5 ++ results/summary.json | 17 +++++ scripts/compare.py | 31 ++++++++- src/kgqa/retrieval/base.py | 15 ++++- tests/test_retrieval.py | 13 ++++ 13 files changed, 365 insertions(+), 9 deletions(-) create mode 100644 app/README.md create mode 100644 app/chat_app.py create mode 100644 app/dashboard.py create mode 100644 requirements-app.txt create mode 100644 results/summary.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93443a7..a07ed3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: python -m pip install numpy scikit-learn scipy requests pytest ruff - name: Lint (ruff) - run: ruff check src scripts tests + run: ruff check src scripts tests app - name: Test (pytest) run: pytest diff --git a/CHANGELOG.md b/CHANGELOG.md index 49fe671..d52050a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- **Interactive UIs** in `app/`: a Gradio chat demo (`chat_app.py`) over the + winning `graph` arm that cites source PubMed IDs, and a Streamlit results + dashboard (`dashboard.py`) that visualizes the ablation, McNemar tests, and + per-class breakdown. `requirements-app.txt`, `make chat` / `make dashboard`. +- `BaseRetriever.chat()` โ€” conversational answer plus the retrieved source pubids. +- `scripts/compare.py` now also writes `results/summary.json` (structured metrics + + contrasts) for the dashboard. + ## [1.0.0] โ€” 2026-06-12 The "fair comparison" revamp: turned a confounded notebook demo into a diff --git a/Makefile b/Makefile index c994e96..face578 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .DEFAULT_GOAL := help -.PHONY: help install install-dev test lint format ingest benchmark compare clean +.PHONY: help install install-dev install-app test lint format ingest benchmark compare chat dashboard clean help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ @@ -11,14 +11,17 @@ install: ## Install runtime dependencies install-dev: ## Install dev dependencies (tests + lint) pip install -r requirements-dev.txt +install-app: ## Install UI dependencies (gradio + streamlit) + pip install -r requirements-app.txt + test: ## Run the test suite pytest lint: ## Lint with ruff - ruff check src scripts tests + ruff check src scripts tests app format: ## Auto-fix lint issues with ruff - ruff check --fix src scripts tests + ruff check --fix src scripts tests app ingest: ## Build the ArangoDB knowledge graph (needs ARANGO_PASS) python scripts/ingest.py @@ -32,6 +35,12 @@ benchmark: ## Run all four arms (needs ARANGO_PASS + Ollama) compare: ## Aggregate results into table, McNemar tests, and figure python scripts/compare.py +chat: ## Launch the Gradio chat demo (needs ArangoDB + Ollama) + python app/chat_app.py + +dashboard: ## Launch the Streamlit results dashboard + streamlit run app/dashboard.py + clean: ## Remove caches and generated vector cache rm -rf .pytest_cache .ruff_cache *.egg-info src/*.egg-info \ pubmed_vectors_cache.pkl diff --git a/README.md b/README.md index 58b92f6..5f511f1 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,27 @@ once, then [`notebooks/02_benchmark.ipynb`](notebooks/02_benchmark.ipynb) (set --- +## Interactive demo & dashboard + +Two optional front-ends (`pip install -r requirements-app.txt`): + +- **Chat demo** (Gradio) โ€” ask a question and get a graph-grounded answer that + cites PubMed IDs, using the winning `graph` arm. Live demo, so it needs + ArangoDB + Ollama running: + ```bash + make chat # or: python app/chat_app.py --share + ``` +- **Results dashboard** (Streamlit) โ€” the 4-arm ablation, McNemar tests, and + per-class breakdown. Reads `results/` only (no LLM or DB), so it deploys to + Streamlit Cloud as a click-to-view link: + ```bash + make dashboard # or: streamlit run app/dashboard.py + ``` + +See [app/README.md](app/README.md) for deployment notes. + +--- + ## Results Seeded random sample of **n = 200** PubMedQA `pqa_labeled` questions (seed 42, diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..9eb30e5 --- /dev/null +++ b/app/README.md @@ -0,0 +1,33 @@ +# Apps + +Two optional front-ends. Install their deps with `pip install -r requirements-app.txt`. + +## `chat_app.py` โ€” live GraphRAG chat (Gradio) + +An interactive assistant over the winning `graph` arm: it retrieves from the +knowledge graph, answers with `deepseek-r1:8b`, and cites the source PubMed IDs. + +```bash +python app/chat_app.py # http://localhost:7860 +python app/chat_app.py --share # public share link (handy on Colab) +python app/chat_app.py --concepts # use the graph_concepts arm +``` + +This is a **live** demo, so it needs the backend running: a reachable ArangoDB +(`ARANGO_HOST` / `ARANGO_PASS`) and Ollama with `deepseek-r1:8b` pulled. To host +it on **Hugging Face Spaces**, set the Space SDK to Gradio and `app_file: +app/chat_app.py`, and point `ARANGO_HOST`/`ARANGO_PASS` at a hosted database via +Space secrets. + +## `dashboard.py` โ€” results dashboard (Streamlit) + +Visualizes the saved benchmark: per-arm accuracy/F1, the paired McNemar tests, +the ablation figure, and (if the per-sample `results/*_results.json` are present) +confusion matrices and per-class F1. + +```bash +streamlit run app/dashboard.py +``` + +No LLM or database required โ€” it only reads `results/`, so it deploys to +**Streamlit Cloud** as a click-to-view link straight from the repo. diff --git a/app/chat_app.py b/app/chat_app.py new file mode 100644 index 0000000..51a74a8 --- /dev/null +++ b/app/chat_app.py @@ -0,0 +1,84 @@ +"""Gradio chat demo over the GraphRAG (`graph`) arm โ€” the ablation's winner. + + python app/chat_app.py # local: http://localhost:7860 + python app/chat_app.py --share # public share link (Colab / remote) + python app/chat_app.py --concepts # use the graph_concepts arm instead + +Requirements: `pip install gradio` (see requirements-app.txt), a reachable +ArangoDB (set ARANGO_HOST / ARANGO_PASS), and a running Ollama with the model +pulled. This is a *live* demo โ€” it retrieves from the graph and calls the LLM. +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "src")) + +EXAMPLES = [ + "Do preoperative statins reduce postoperative atrial fibrillation?", + "Is vitamin D deficiency associated with increased mortality?", + "Does laparoscopic surgery reduce hospital stay versus open surgery?", +] + + +def _strip_think(text: str) -> str: + """Drop the reasoning model's ... block for a clean answer.""" + return re.sub(r".*?", "", text, flags=re.DOTALL).strip() + + +def build_retriever(use_concepts: bool): + from kgqa.config import ArangoConfig + from kgqa.models import connect_arango, load_encoder, load_reranker + from kgqa.retrieval import ChunkStore, GraphRetriever + + db = connect_arango(ArangoConfig()) + cache = os.path.join(ROOT, "pubmed_vectors_cache.pkl") + store = ChunkStore.from_arango(db, cache_file=cache) + print(f"[demo] {len(store):,} chunks loaded") + return GraphRetriever(store, load_encoder(), db, + reranker=load_reranker(), use_concepts=use_concepts) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--share", action="store_true", help="create a public share link") + parser.add_argument("--concepts", action="store_true", help="use the graph_concepts arm") + parser.add_argument("--port", type=int, default=7860) + args = parser.parse_args() + + import gradio as gr + + rag = build_retriever(args.concepts) + + def respond(message, history): + result = rag.chat(message) + answer = _strip_think(result["answer"]) or "_No answer produced._" + sources = result.get("sources", []) + if sources: + links = "\n".join( + f"- [PMID {pid}](https://pubmed.ncbi.nlm.nih.gov/{pid}/)" for pid in sources + ) + answer += f"\n\n**Sources**\n{links}" + return answer + + gr.ChatInterface( + fn=respond, + title="PubMed GraphRAG assistant", + description=( + "Graph-augmented retrieval over PubMedQA: matched chunks are expanded " + "to full abstracts via the knowledge graph, then answered by " + "deepseek-r1:8b. Answers cite the source PubMed IDs." + ), + examples=EXAMPLES, + theme="soft", + ).launch(share=args.share, server_port=args.port) + + +if __name__ == "__main__": + main() diff --git a/app/dashboard.py b/app/dashboard.py new file mode 100644 index 0000000..e1f41aa --- /dev/null +++ b/app/dashboard.py @@ -0,0 +1,126 @@ +"""Streamlit dashboard for the GraphRAG vs PlainRAG ablation results. + + pip install streamlit # see requirements-app.txt + streamlit run app/dashboard.py + +Reads results/summary.json (always) for the headline metrics and significance +tests, and results/{arm}_results.json (if present) for confusion matrices and +per-class F1. No LLM or database needed โ€” it just visualizes the saved results, +so it deploys cleanly to Streamlit Cloud. +""" + +from __future__ import annotations + +import json +import os +import sys + +import pandas as pd +import streamlit as st + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "src")) +RESULTS_DIR = os.path.join(ROOT, "results") +ARM_ORDER = ["plain", "plain_rr", "graph", "graph_concepts"] +LABELS = ["yes", "no", "maybe"] + + +@st.cache_data +def load_summary(): + with open(os.path.join(RESULTS_DIR, "summary.json")) as f: + return json.load(f) + + +@st.cache_data +def load_raw(): + raw = {} + for arm in ARM_ORDER: + path = os.path.join(RESULTS_DIR, f"{arm}_results.json") + if os.path.exists(path): + with open(path) as f: + raw[arm] = json.load(f) + return raw + + +def main(): + st.set_page_config(page_title="GraphRAG vs PlainRAG โ€” PubMedQA", layout="wide") + st.title("GraphRAG vs PlainRAG โ€” a fair 4-arm ablation on PubMedQA") + + try: + summary = load_summary() + except FileNotFoundError: + st.error("results/summary.json not found. Run `python scripts/compare.py` first.") + st.stop() + + st.caption( + f"n = {summary['n']} questions ยท seed {summary['seed']} ยท " + f"{summary['model']} ยท {summary['dataset']}. " + "Every layer held constant; only the retrieval strategy changes." + ) + + arms = summary["arms"] + best = max(arms, key=lambda a: a["accuracy"]) + + # โ”€โ”€ headline metrics โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + cols = st.columns(len(arms)) + for col, arm in zip(cols, arms, strict=False): + delta = f"{arm['accuracy'] - arms[0]['accuracy']:+.1f} pp vs plain" \ + if arm["arm"] != "plain" else None + col.metric(arm["arm"], f"{arm['accuracy']:.1f}%", delta) + + st.success( + f"**Winner: `{best['arm']}` at {best['accuracy']:.1f}%.** The decisive, " + "statistically significant gain comes from parent-document expansion " + "(`plain_rr โ†’ graph`: +22.5 pp, McNemar p < 0.0001). The reranker helps " + "but isn't significant; the concept hop doesn't help and costs ~5ร— latency." + ) + + left, right = st.columns([3, 2]) + + with left: + st.subheader("Accuracy & macro-F1 by arm") + df = pd.DataFrame(arms).set_index("arm") + st.bar_chart(df[["accuracy", "macro_f1"]]) + st.dataframe( + df[["adds", "accuracy", "macro_f1", "avg_latency", "samples"]], + use_container_width=True, + ) + + with right: + st.subheader("Significance (paired McNemar)") + cdf = pd.DataFrame(summary["contrasts"]) + cdf["contrast"] = cdf["from"] + " โ†’ " + cdf["to"] + " (" + cdf["effect"] + ")" + cdf["significant"] = cdf["significant"].map({True: "yes", False: "no"}) + st.dataframe( + cdf[["contrast", "delta_acc", "gains", "losses", "p_value", "significant"]], + use_container_width=True, hide_index=True, + ) + fig_path = os.path.join(RESULTS_DIR, "ablation.png") + if os.path.exists(fig_path): + st.image(fig_path, caption="4-arm ablation", use_container_width=True) + + # โ”€โ”€ optional: per-class detail from raw per-sample results โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + raw = load_raw() + if raw: + st.subheader("Per-class detail") + from sklearn.metrics import confusion_matrix, f1_score + tabs = st.tabs([a for a in ARM_ORDER if a in raw]) + for tab, arm in zip(tabs, [a for a in ARM_ORDER if a in raw], strict=False): + with tab: + r = raw[arm] + cm = confusion_matrix(r["y_true"], r["y_pred"], labels=LABELS) + st.write("Confusion matrix (rows = actual, cols = predicted)") + st.dataframe(pd.DataFrame(cm, index=LABELS, columns=LABELS)) + f1s = f1_score(r["y_true"], r["y_pred"], labels=LABELS, + average=None, zero_division=0) + st.write("Per-class F1") + st.bar_chart(pd.Series(f1s, index=LABELS)) + else: + st.info( + "Add the per-sample `results/{arm}_results.json` files to unlock " + "confusion matrices and per-class F1 here." + ) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 3013273..9612b6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ addopts = "-q" [tool.ruff] line-length = 100 -src = ["src", "scripts", "tests"] +src = ["src", "scripts", "tests", "app"] target-version = "py310" [tool.ruff.lint] @@ -47,3 +47,4 @@ ignore = ["E501"] # line length handled by formatter; long AQL strings are fine [tool.ruff.lint.per-file-ignores] "scripts/*" = ["E402"] # sys.path insert before imports is intentional +"app/*" = ["E402"] # same: sys.path setup precedes imports diff --git a/requirements-app.txt b/requirements-app.txt new file mode 100644 index 0000000..ea3ec7b --- /dev/null +++ b/requirements-app.txt @@ -0,0 +1,5 @@ +-r requirements.txt + +# Interactive UIs (app/) +gradio>=4.0 # app/chat_app.py โ€” live GraphRAG chat demo +streamlit>=1.30 # app/dashboard.py โ€” benchmark results dashboard diff --git a/results/summary.json b/results/summary.json new file mode 100644 index 0000000..afae7fb --- /dev/null +++ b/results/summary.json @@ -0,0 +1,17 @@ +{ + "n": 200, + "seed": 42, + "model": "deepseek-r1:8b", + "dataset": "PubMedQA (pqa_labeled)", + "arms": [ + {"arm": "plain", "accuracy": 30.0, "macro_f1": 29.69, "avg_latency": 6.4, "samples": 200, "adds": "baseline chunk RAG"}, + {"arm": "plain_rr", "accuracy": 37.0, "macro_f1": 35.21, "avg_latency": 6.6, "samples": 200, "adds": "+ cross-encoder reranker"}, + {"arm": "graph", "accuracy": 59.5, "macro_f1": 50.51, "avg_latency": 7.5, "samples": 200, "adds": "+ parent-paper expansion"}, + {"arm": "graph_concepts", "accuracy": 57.5, "macro_f1": 49.97, "avg_latency": 40.8, "samples": 200, "adds": "+ MeSH concept hop"} + ], + "contrasts": [ + {"from": "plain", "to": "plain_rr", "effect": "reranker", "delta_acc": 7.0, "gains": 35, "losses": 21, "p_value": 0.0814, "significant": false}, + {"from": "plain_rr", "to": "graph", "effect": "parent expansion", "delta_acc": 22.5, "gains": 71, "losses": 26, "p_value": 0.0000, "significant": true}, + {"from": "graph", "to": "graph_concepts", "effect": "concept hop", "delta_acc": -2.0, "gains": 26, "losses": 30, "p_value": 0.6889, "significant": false} + ] +} diff --git a/scripts/compare.py b/scripts/compare.py index b48bafe..5f9e4b0 100644 --- a/scripts/compare.py +++ b/scripts/compare.py @@ -16,15 +16,22 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.join(ROOT, "src")) +from kgqa.config import DATASET_NAME, LLM_MODEL, RANDOM_SEED # noqa: E402 from kgqa.evaluation import mcnemar_test # noqa: E402 RESULTS_DIR = os.path.join(ROOT, "results") ARM_ORDER = ["plain", "plain_rr", "graph", "graph_concepts"] +ARM_ADDS = { + "plain": "baseline chunk RAG", + "plain_rr": "+ cross-encoder reranker", + "graph": "+ parent-paper expansion", + "graph_concepts": "+ MeSH concept hop", +} # Adjacent-arm contrasts that isolate each component's contribution. CONTRASTS = [ - ("plain", "plain_rr", "reranker effect"), - ("plain_rr", "graph", "parent-expansion effect"), - ("graph", "graph_concepts", "concept-hop effect"), + ("plain", "plain_rr", "reranker"), + ("plain_rr", "graph", "parent expansion"), + ("graph", "graph_concepts", "concept hop"), ] @@ -62,6 +69,7 @@ def main(): lines = ["| Arm | Accuracy | Macro F1 | Avg latency (s) | n |", "| --- | --- | --- | --- | --- |"] + arms_json, contrasts_json, max_n = [], [], 0 print("\n" + "=" * 64) print(" RESULTS SUMMARY") print("=" * 64) @@ -71,8 +79,12 @@ def main(): r = results[arm] acc, f1 = r["accuracy"] * 100, r.get("macro_f1", 0) * 100 lat, n = r["avg_latency"], r["samples"] + max_n = max(max_n, n) print(f" {arm:<16} acc={acc:6.2f}% f1={f1:6.2f}% lat={lat:5.1f}s n={n}") lines.append(f"| {arm} | {acc:.2f}% | {f1:.2f}% | {lat:.1f} | {n} |") + arms_json.append({"arm": arm, "accuracy": round(acc, 2), "macro_f1": round(f1, 2), + "avg_latency": round(lat, 1), "samples": n, + "adds": ARM_ADDS.get(arm, "")}) print("\n" + "=" * 64) print(" PAIRED McNEMAR TESTS (adjacent ablation contrasts)") @@ -96,12 +108,25 @@ def main(): f" p={test['p_value']:.4f} sig={sig}") lines.append(f"| {a_name} โ†’ {b_name} ({desc}) | {d:+.2f} | {test['b_gains']} " f"| {test['c_losses']} | {test['p_value']:.4f} | {sig} |") + contrasts_json.append({"from": a_name, "to": b_name, "effect": desc, + "delta_acc": round(d, 2), "gains": test["b_gains"], + "losses": test["c_losses"], + "p_value": round(test["p_value"], 4), + "significant": test["significant_at_0.05"]}) md_path = os.path.join(RESULTS_DIR, "summary.md") with open(md_path, "w") as f: f.write("\n".join(lines) + "\n") print(f"\nWrote {md_path}") + json_path = os.path.join(RESULTS_DIR, "summary.json") + with open(json_path, "w") as f: + json.dump({"n": max_n, "seed": RANDOM_SEED, "model": LLM_MODEL, + "dataset": "PubMedQA (pqa_labeled)" if "PubMedQA" in DATASET_NAME + else DATASET_NAME, + "arms": arms_json, "contrasts": contrasts_json}, f, indent=2) + print(f"Wrote {json_path}") + _plot(results) diff --git a/src/kgqa/retrieval/base.py b/src/kgqa/retrieval/base.py index 8a67a5d..0bfbb4b 100644 --- a/src/kgqa/retrieval/base.py +++ b/src/kgqa/retrieval/base.py @@ -16,7 +16,7 @@ from ..config import TOP_K_CANDIDATES, TOP_K_FINAL from ..llm import call_ollama -from ..prompts import BENCHMARK_SYSTEM_PROMPT, build_prompt +from ..prompts import BENCHMARK_SYSTEM_PROMPT, CHAT_SYSTEM_PROMPT, build_prompt @dataclass @@ -166,3 +166,16 @@ def answer_benchmark(self, question: str) -> str: context = self.retrieve(question) return call_ollama(build_prompt(context, question), system=BENCHMARK_SYSTEM_PROMPT) + + def chat(self, question: str, temperature: float = 0.3) -> dict: + """Conversational answer plus the source paper pubids it retrieved. + + Runs retrieval once and returns the cited papers (their PubMedQA pubids, + which are real PubMed IDs) so a UI can link back to the sources. + """ + candidates = self._select(question) + context = self._build_context(question, candidates) + answer = call_ollama(build_prompt(context, question), + system=CHAT_SYSTEM_PROMPT, temperature=temperature) + sources = list(dict.fromkeys(c.paper_key for c in candidates)) + return {"answer": answer, "sources": sources, "context": context} diff --git a/tests/test_retrieval.py b/tests/test_retrieval.py index 9cbca7a..f784fd4 100644 --- a/tests/test_retrieval.py +++ b/tests/test_retrieval.py @@ -86,3 +86,16 @@ def execute(*a, **k): ctx = r.retrieve("aspirin heart attack") assert "=== STUDY 1 ===" in ctx assert "aspirin" in ctx + + +def test_chat_returns_answer_and_source_pubids(fake_encoder, fake_reranker, monkeypatch): + import kgqa.retrieval.base as base + monkeypatch.setattr(base, "call_ollama", + lambda *a, **k: "reasoning Yes, it does.") + store = make_store(fake_encoder) + db = FakeDB(abstracts={"1": "FULL ABS 1: aspirin", "2": "x", "3": "y"}) + r = GraphRetriever(store, fake_encoder, db, reranker=fake_reranker, top_k_final=1) + out = r.chat("does aspirin reduce heart attack risk") + assert set(out) >= {"answer", "sources", "context"} + assert out["sources"] == ["1"] # the retrieved paper's pubid + assert "Yes" in out["answer"] From 85522acd43a6cad8725bbdfe12f0517bca7dd90c Mon Sep 17 00:00:00 2001 From: vardhjain Date: Sat, 13 Jun 2026 12:39:39 -0400 Subject: [PATCH 15/23] Make the Streamlit dashboard one-click deployable on Streamlit Cloud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard is the hosted public demo (no LLM/DB/GPU โ€” reads results/ only). The chat stays local/Colab (it needs a GPU + a persistent ArangoDB). - app/requirements.txt: light deploy deps (streamlit/pandas/scikit-learn). Streamlit Cloud reads the entrypoint's dir first, so this is used and the heavy root requirements.txt is ignored for the deploy โ€” benchmark users keep their full deps. - .streamlit/config.toml: clean light theme matching the ablation figure. - dashboard: richer set_page_config (icon, About/menu links, methodology expander); per-class section degrades cleanly when raw results are absent. - README: "Open in Streamlit" badge + "Live demo" section; app/README has the exact Streamlit Cloud deploy steps. --- .streamlit/config.toml | 11 +++++++++++ CHANGELOG.md | 3 +++ README.md | 39 ++++++++++++++++++++++++--------------- app/README.md | 25 ++++++++++++++++++++++--- app/dashboard.py | 34 ++++++++++++++++++++++++++++------ app/requirements.txt | 14 ++++++++++++++ 6 files changed, 102 insertions(+), 24 deletions(-) create mode 100644 .streamlit/config.toml create mode 100644 app/requirements.txt diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000..3fabf7a --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,11 @@ +# Theme for the Streamlit dashboard (app/dashboard.py). Read by `streamlit run` +# locally and by Streamlit Community Cloud. Only long-stable keys are used so it +# renders correctly on any recent Streamlit version. Palette matches the +# matplotlib figure in results/ablation.png (blue primary). +[theme] +base = "light" +primaryColor = "#2196F3" +backgroundColor = "#FFFFFF" +secondaryBackgroundColor = "#F5F7FA" +textColor = "#1A2027" +font = "sans serif" diff --git a/CHANGELOG.md b/CHANGELOG.md index d52050a..009261f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `BaseRetriever.chat()` โ€” conversational answer plus the retrieved source pubids. - `scripts/compare.py` now also writes `results/summary.json` (structured metrics + contrasts) for the dashboard. +- One-click **Streamlit Community Cloud** deploy for the dashboard: a light + `app/requirements.txt` (picked up before the heavy root file), a themed + `.streamlit/config.toml`, a richer page config, and a README live-demo badge. ## [1.0.0] โ€” 2026-06-12 diff --git a/README.md b/README.md index 5f511f1..ce453f0 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Lint: ruff](https://img.shields.io/badge/lint-ruff-261230.svg)](https://github.com/astral-sh/ruff) +[![Open in Streamlit](https://static.streamlit.io/badges/streamlit_badge_black_white.svg)](https://kgqa-ablation.streamlit.app) A controlled study of **what a knowledge graph actually contributes** to retrieval-augmented question answering on biomedical literature @@ -131,24 +132,32 @@ once, then [`notebooks/02_benchmark.ipynb`](notebooks/02_benchmark.ipynb) (set --- -## Interactive demo & dashboard +## Live demo -Two optional front-ends (`pip install -r requirements-app.txt`): +**[โ–ถ Open the results dashboard](https://kgqa-ablation.streamlit.app)** โ€” an +interactive Streamlit dashboard of the 4-arm ablation: headline accuracy, the +paired McNemar significance tests, latency, and (when raw results are present) +per-class confusion matrices. No setup, no login โ€” it reads the committed +`results/` artifacts, so it needs no LLM, database, or GPU. -- **Chat demo** (Gradio) โ€” ask a question and get a graph-grounded answer that - cites PubMed IDs, using the winning `graph` arm. Live demo, so it needs - ArangoDB + Ollama running: - ```bash - make chat # or: python app/chat_app.py --share - ``` -- **Results dashboard** (Streamlit) โ€” the 4-arm ablation, McNemar tests, and - per-class breakdown. Reads `results/` only (no LLM or DB), so it deploys to - Streamlit Cloud as a click-to-view link: - ```bash - make dashboard # or: streamlit run app/dashboard.py - ``` +Run the dashboard locally: -See [app/README.md](app/README.md) for deployment notes. +```bash +pip install -r app/requirements.txt +make dashboard # or: streamlit run app/dashboard.py +``` + +**Chat demo** โ€” a Gradio assistant that answers from the graph and cites PubMed +IDs (the winning `graph` arm). It's a *live* pipeline that needs a reachable +ArangoDB + Ollama, so run it yourself (best on a GPU Colab): + +```bash +pip install -r requirements-app.txt +python app/chat_app.py --share # public Gradio link +``` + +A hosted always-on chat isn't provided on purpose โ€” it would need a paid GPU and +a persistent ArangoDB. See [app/README.md](app/README.md) for details. --- diff --git a/app/README.md b/app/README.md index 9eb30e5..a9e4fab 100644 --- a/app/README.md +++ b/app/README.md @@ -23,11 +23,30 @@ Space secrets. Visualizes the saved benchmark: per-arm accuracy/F1, the paired McNemar tests, the ablation figure, and (if the per-sample `results/*_results.json` are present) -confusion matrices and per-class F1. +confusion matrices and per-class F1. No LLM or database required โ€” it only reads +`results/`, so it's light and deploys anywhere. ```bash +pip install -r app/requirements.txt # light: streamlit + pandas + scikit-learn streamlit run app/dashboard.py ``` -No LLM or database required โ€” it only reads `results/`, so it deploys to -**Streamlit Cloud** as a click-to-view link straight from the repo. +### Deploy to Streamlit Community Cloud (free, always-on) + +The dashboard is the project's hosted demo. `app/requirements.txt` sits next to +the entrypoint so Streamlit Cloud installs only the light deps (it searches the +entrypoint's directory before the heavy root `requirements.txt`). + +1. Push these to `main`: `app/dashboard.py`, `app/requirements.txt`, + `.streamlit/config.toml`, and the `results/` artifacts. +2. Go to , sign in with GitHub, authorize the repo. +3. **Create app โ†’ Deploy a public app from GitHub.** +4. Repository `vardhjain/Knowledge_Graph_Question_Answering`, Branch `main`, + **Main file path `app/dashboard.py`**. +5. Advanced settings โ†’ Python 3.11; set the subdomain to `kgqa-ablation` + (matches the README badge โ†’ `https://kgqa-ablation.streamlit.app`). +6. **Deploy.** Copy the final URL; if you changed the subdomain, update the + badge + "Live demo" link in the root README. + +> Tip: commit the per-sample `results/{arm}_results.json` files too (if you still +> have them from the benchmark run) to light up the confusion-matrix section. diff --git a/app/dashboard.py b/app/dashboard.py index e1f41aa..1000b54 100644 --- a/app/dashboard.py +++ b/app/dashboard.py @@ -43,7 +43,22 @@ def load_raw(): def main(): - st.set_page_config(page_title="GraphRAG vs PlainRAG โ€” PubMedQA", layout="wide") + repo = "https://github.com/vardhjain/Knowledge_Graph_Question_Answering" + st.set_page_config( + page_title="GraphRAG vs PlainRAG โ€” PubMedQA Ablation", + page_icon="๐Ÿงฌ", + layout="wide", + initial_sidebar_state="collapsed", + menu_items={ + "Get Help": repo, + "Report a bug": f"{repo}/issues", + "About": ( + "### GraphRAG vs PlainRAG โ€” a fair 4-arm ablation on PubMedQA\n" + "Every layer held constant; only the retrieval strategy changes.\n\n" + f"Source: [{repo}]({repo})" + ), + }, + ) st.title("GraphRAG vs PlainRAG โ€” a fair 4-arm ablation on PubMedQA") try: @@ -75,6 +90,16 @@ def main(): "but isn't significant; the concept hop doesn't help and costs ~5ร— latency." ) + with st.expander("How this is measured (fairness)"): + st.markdown( + "All four arms share the same corpus, chunking, embedder, reranker, " + "prompt, LLM, seed, and top-k โ€” **only the retrieval strategy changes**, " + "so each adjacent contrast isolates one component. Significance is a " + "paired **McNemar** test on the same questions. The graph context is " + "leakage-free: no question-derived titles or gold labels ever reach the " + "prompt." + ) + left, right = st.columns([3, 2]) with left: @@ -115,11 +140,8 @@ def main(): average=None, zero_division=0) st.write("Per-class F1") st.bar_chart(pd.Series(f1s, index=LABELS)) - else: - st.info( - "Add the per-sample `results/{arm}_results.json` files to unlock " - "confusion matrices and per-class F1 here." - ) + + st.caption(f"Source: {repo}") if __name__ == "__main__": diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..9eee92c --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,14 @@ +# Streamlit Community Cloud deploy dependencies for app/dashboard.py ONLY. +# +# This file lives next to the entrypoint on purpose: Community Cloud searches the +# entrypoint's directory FIRST, so this light file is used and the heavy root +# requirements.txt (torch, sentence-transformers, datasets, python-arango) is +# never installed for the hosted dashboard. Keep the deploy's "Main file path" +# set to app/dashboard.py. +# +# Also handy locally โ€” `pip install -r app/requirements.txt` runs just the +# dashboard. It needs streamlit + pandas (+ scikit-learn, used lazily for the +# per-class confusion matrices when results/{arm}_results.json files are present). +streamlit>=1.30 +pandas>=2.0 +scikit-learn>=1.3 From c308933a51fe0d77ac6e2da67a4880a11d95c156 Mon Sep 17 00:00:00 2001 From: vardhjain Date: Sat, 13 Jun 2026 13:02:47 -0400 Subject: [PATCH 16/23] Use shields.io badge for the live demo (more robust than static.streamlit.io) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ce453f0..0547d87 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Lint: ruff](https://img.shields.io/badge/lint-ruff-261230.svg)](https://github.com/astral-sh/ruff) -[![Open in Streamlit](https://static.streamlit.io/badges/streamlit_badge_black_white.svg)](https://kgqa-ablation.streamlit.app) +[![Live Demo](https://img.shields.io/badge/Streamlit-Live%20Demo-FF4B4B?logo=streamlit&logoColor=white)](https://kgqa-ablation.streamlit.app) A controlled study of **what a knowledge graph actually contributes** to retrieval-augmented question answering on biomedical literature From 925055d8628fad5880b40622feabb751099bd266 Mon Sep 17 00:00:00 2001 From: vardhjain Date: Tue, 23 Jun 2026 12:23:18 -0400 Subject: [PATCH 17/23] Polish README presentation: centered hero, quick-nav, emoji sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the presentation style of the EthicLens repo โ€” centered title with emoji + tagline, a badge row, a quick-nav line (live demo / results / why / setup), and emoji on section headers. Nav-target headings kept plain so anchors stay stable. --- README.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0547d87..658071c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# Knowledge Graph Question Answering โ€” GraphRAG vs PlainRAG, done fairly +
+ +# ๐Ÿงฌ Knowledge Graph Question Answering + +### GraphRAG vs PlainRAG on PubMedQA โ€” a fair, leakage-free, statistically-tested ablation [![CI](https://github.com/vardhjain/Knowledge_Graph_Question_Answering/actions/workflows/ci.yml/badge.svg)](https://github.com/vardhjain/Knowledge_Graph_Question_Answering/actions/workflows/ci.yml) [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) @@ -6,6 +10,10 @@ [![Lint: ruff](https://img.shields.io/badge/lint-ruff-261230.svg)](https://github.com/astral-sh/ruff) [![Live Demo](https://img.shields.io/badge/Streamlit-Live%20Demo-FF4B4B?logo=streamlit&logoColor=white)](https://kgqa-ablation.streamlit.app) +[**โ–ถ Live demo**](https://kgqa-ablation.streamlit.app)  ยท  [**Results**](#results)  ยท  [**Why it's fair**](#why-the-original-comparison-was-unfair-and-what-changed)  ยท  [**Setup**](#setup) + +
+ A controlled study of **what a knowledge graph actually contributes** to retrieval-augmented question answering on biomedical literature ([PubMedQA](https://pubmedqa.github.io/)). @@ -54,7 +62,7 @@ single-abstract dataset. We report that honestly rather than bury it โ€” see --- -## Repository layout +## ๐Ÿ—‚๏ธ Repository layout ``` src/kgqa/ importable package โ€” single source of truth @@ -79,7 +87,7 @@ tests/ pytest suite (runs on CPU, no Ollama/ArangoDB needed) docs/ project report (PDF) and slides (PPTX) ``` -## Stack +## ๐Ÿงฐ Stack - **Dataset:** PubMedQA (`pqa_labeled` for evaluation, `pqa_unlabeled` for corpus) - **Embeddings:** `all-MiniLM-L6-v2` (384-dim) @@ -115,7 +123,7 @@ export ARANGO_PASS=devpassword # PowerShell: $env:ARANGO_PASS="devpassw ollama serve & ollama pull deepseek-r1:8b ``` -## Running the benchmark +## โš™๏ธ Running the benchmark ```bash python scripts/ingest.py # build the graph once @@ -132,7 +140,7 @@ once, then [`notebooks/02_benchmark.ipynb`](notebooks/02_benchmark.ipynb) (set --- -## Live demo +## โ–ถ Live demo **[โ–ถ Open the results dashboard](https://kgqa-ablation.streamlit.app)** โ€” an interactive Streamlit dashboard of the 4-arm ablation: headline accuracy, the @@ -209,7 +217,7 @@ The macro-F1 / accuracy gap on the graph arms reflects weak recall on the rare --- -## Development +## ๐Ÿงช Development ```bash make install-dev # deps for tests + lint @@ -223,18 +231,18 @@ fakes for the encoder, reranker, and ArangoDB, so the heavy ML dependencies are never needed just to verify the logic. Optionally `pre-commit install` to run ruff automatically on each commit. -## Contributing +## ๐Ÿค Contributing Contributions are welcome โ€” see [CONTRIBUTING.md](CONTRIBUTING.md) for setup, the project layout, and the fairness ground rules. Changes are tracked in [CHANGELOG.md](CHANGELOG.md); please be kind and follow the [Code of Conduct](CODE_OF_CONDUCT.md). -## Citing +## ๐Ÿ“š Citing If this project or its findings are useful in your work, please cite it โ€” see [CITATION.cff](CITATION.cff) (GitHub renders a "Cite this repository" button). -## License +## ๐Ÿ“„ License [MIT](LICENSE). From bcf10c807e2059ab4e7b3bbdd61cbfd3b0ae908e Mon Sep 17 00:00:00 2001 From: vardhjain Date: Tue, 23 Jun 2026 12:31:15 -0400 Subject: [PATCH 18/23] Add coverage to CI (Codecov) and raise it to 82% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new unit tests for data sampling, the Ollama client, evaluation report/save, and ChunkStore.from_dataset (mocking the datasets/requests boundaries) โ€” 18 -> 24 tests - CI runs pytest --cov and uploads to Codecov; README gets a coverage badge - pytest-cov added to dev deps; coverage config in pyproject --- .github/workflows/ci.yml | 11 ++++++++-- README.md | 1 + pyproject.toml | 6 ++++++ requirements-dev.txt | 1 + tests/test_data.py | 43 ++++++++++++++++++++++++++++++++++++++++ tests/test_evaluation.py | 22 ++++++++++++++++++++ tests/test_llm.py | 33 ++++++++++++++++++++++++++++++ tests/test_retrieval.py | 12 +++++++++++ 8 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 tests/test_data.py create mode 100644 tests/test_llm.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a07ed3c..3f0a6c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,10 +25,17 @@ jobs: # datasets) are imported lazily, so unit tests need only this light set. run: | python -m pip install --upgrade pip - python -m pip install numpy scikit-learn scipy requests pytest ruff + python -m pip install numpy scikit-learn scipy requests pytest pytest-cov ruff - name: Lint (ruff) run: ruff check src scripts tests app - name: Test (pytest) - run: pytest + run: pytest --cov=kgqa --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.11' + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + fail_ci_if_error: false diff --git a/README.md b/README.md index 658071c..183afb5 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ### GraphRAG vs PlainRAG on PubMedQA โ€” a fair, leakage-free, statistically-tested ablation [![CI](https://github.com/vardhjain/Knowledge_Graph_Question_Answering/actions/workflows/ci.yml/badge.svg)](https://github.com/vardhjain/Knowledge_Graph_Question_Answering/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/vardhjain/Knowledge_Graph_Question_Answering/graph/badge.svg)](https://codecov.io/gh/vardhjain/Knowledge_Graph_Question_Answering) [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Lint: ruff](https://img.shields.io/badge/lint-ruff-261230.svg)](https://github.com/astral-sh/ruff) diff --git a/pyproject.toml b/pyproject.toml index 9612b6d..4fed7f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,12 @@ pythonpath = ["src", "."] testpaths = ["tests"] addopts = "-q" +[tool.coverage.run] +source = ["kgqa"] + +[tool.coverage.report] +show_missing = true + [tool.ruff] line-length = 100 src = ["src", "scripts", "tests", "app"] diff --git a/requirements-dev.txt b/requirements-dev.txt index 1008b1c..23b34e5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,4 +2,5 @@ # Testing & linting (CI) pytest>=8.0 +pytest-cov>=5.0 ruff>=0.4.0 diff --git a/tests/test_data.py b/tests/test_data.py new file mode 100644 index 0000000..57159db --- /dev/null +++ b/tests/test_data.py @@ -0,0 +1,43 @@ +"""Tests for dataset sampling and chunking โ€” the `datasets` dependency is faked +so these run without it installed and without any download.""" + +from __future__ import annotations + +import sys +import types + + +def _fake_datasets(monkeypatch, rows): + mod = types.ModuleType("datasets") + mod.load_dataset = lambda *a, **k: rows + monkeypatch.setitem(sys.modules, "datasets", mod) + + +def test_load_benchmark_samples_seeded_and_filtered(monkeypatch): + from kgqa import data + + rows = [{"pubid": i, "question": f"q{i}", "final_decision": ["yes", "no", "maybe"][i % 3]} + for i in range(30)] + rows.append({"pubid": 900, "question": "", "final_decision": "yes"}) # dropped: no question + rows.append({"pubid": 901, "question": "x", "final_decision": None}) # dropped: no label + _fake_datasets(monkeypatch, rows) + + a = data.load_benchmark_samples(n=10, seed=42) + b = data.load_benchmark_samples(n=10, seed=42) + assert len(a) == 10 + assert [s.pubid for s in a] == [s.pubid for s in b] # deterministic + assert all(s.question and s.final_decision for s in a) # filtered + assert all(isinstance(s.pubid, str) for s in a) # pubid stringified + assert data.load_benchmark_samples(n=10, seed=7) != a # seed changes order + + +def test_iter_chunks_skips_empty_and_yields_indices(monkeypatch): + from kgqa import data + + rows = [{"pubid": 5, "context": {"contexts": ["alpha", "beta", " "]}}] + _fake_datasets(monkeypatch, rows) + + chunks = list(data.iter_chunks(include_unlabeled=False)) + assert ("5", 0, "alpha") in chunks + assert ("5", 1, "beta") in chunks + assert len(chunks) == 2 # blank section dropped diff --git a/tests/test_evaluation.py b/tests/test_evaluation.py index aa9c5d1..067b701 100644 --- a/tests/test_evaluation.py +++ b/tests/test_evaluation.py @@ -47,3 +47,25 @@ def test_mcnemar_no_difference(): res = mcnemar_test(gt, gt, gt) assert res["discordant"] == 0 assert res["p_value"] == 1.0 + + +def test_mcnemar_length_mismatch_raises(): + import pytest + with pytest.raises(ValueError): + mcnemar_test(["yes"], ["yes"], ["yes", "no"]) + + +def test_report_and_save_roundtrip(tmp_path): + import json + ev = Evaluator("graph") + ev.record("yes", "yes", 1.0, "1") + ev.record("no", "yes", 2.0, "2") + summary = ev.report() + assert summary["model"] == "graph" and summary["samples"] == 2 + assert "macro_f1" in summary + + path = tmp_path / "results.json" + ev.save(str(path)) + loaded = json.loads(path.read_text()) + assert loaded["samples"] == 2 + assert loaded["ids"] == ["1", "2"] diff --git a/tests/test_llm.py b/tests/test_llm.py new file mode 100644 index 0000000..4960521 --- /dev/null +++ b/tests/test_llm.py @@ -0,0 +1,33 @@ +"""Tests for the Ollama client โ€” requests.post is faked, so no server is needed.""" + +from __future__ import annotations + + +def test_call_ollama_builds_payload_and_returns_content(monkeypatch): + import kgqa.llm as llm + + captured = {} + + class FakeResp: + def raise_for_status(self): + pass + + def json(self): + return {"message": {"content": "the answer"}} + + def fake_post(url, json=None, timeout=None): + captured["url"] = url + captured["payload"] = json + return FakeResp() + + monkeypatch.setattr(llm.requests, "post", fake_post) + + out = llm.call_ollama("my prompt", system="be helpful", temperature=0.0) + assert out == "the answer" + + payload = captured["payload"] + assert payload["messages"][0] == {"role": "system", "content": "be helpful"} + assert payload["messages"][-1] == {"role": "user", "content": "my prompt"} + assert payload["stream"] is False + assert "num_predict" in payload["options"] # generation cap is applied + assert "keep_alive" in payload # model kept resident diff --git a/tests/test_retrieval.py b/tests/test_retrieval.py index f784fd4..9fe8c1b 100644 --- a/tests/test_retrieval.py +++ b/tests/test_retrieval.py @@ -99,3 +99,15 @@ def test_chat_returns_answer_and_source_pubids(fake_encoder, fake_reranker, monk assert set(out) >= {"answer", "sources", "context"} assert out["sources"] == ["1"] # the retrieved paper's pubid assert "Yes" in out["answer"] + + +def test_chunkstore_from_dataset_builds_corpus(monkeypatch, fake_encoder): + import kgqa.data as data + from kgqa.retrieval import ChunkStore + + monkeypatch.setattr(data, "iter_chunks", + lambda include_unlabeled=True: iter([("1", 0, "alpha"), ("2", 0, "beta")])) + store = ChunkStore.from_dataset(fake_encoder, include_unlabeled=False) + assert len(store) == 2 + assert store.paper_keys == ["1", "2"] + assert store.ids == ["Chunks/1_0", "Chunks/2_0"] From 0c0738b5217c4b9da3ade157d06ee8e6347a9e95 Mon Sep 17 00:00:00 2001 From: vardhjain Date: Tue, 23 Jun 2026 12:32:54 -0400 Subject: [PATCH 19/23] Add GitHub Pages docs site + README docs section - docs/index.md + docs/_config.yml (Jekyll Cayman theme): a landing page with the architecture diagram, results table, ablation figure, honest findings, and links to the demo / report / slides - README: docs badge + a Documentation section Enable via Settings -> Pages -> Deploy from branch -> main -> /docs. --- README.md | 7 +++++++ docs/_config.yml | 10 ++++++++++ docs/index.md | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 docs/_config.yml create mode 100644 docs/index.md diff --git a/README.md b/README.md index 183afb5..0c77c31 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Lint: ruff](https://img.shields.io/badge/lint-ruff-261230.svg)](https://github.com/astral-sh/ruff) [![Live Demo](https://img.shields.io/badge/Streamlit-Live%20Demo-FF4B4B?logo=streamlit&logoColor=white)](https://kgqa-ablation.streamlit.app) +[![Docs](https://img.shields.io/badge/docs-online-1f6feb)](https://vardhjain.github.io/Knowledge_Graph_Question_Answering/) [**โ–ถ Live demo**](https://kgqa-ablation.streamlit.app)  ยท  [**Results**](#results)  ยท  [**Why it's fair**](#why-the-original-comparison-was-unfair-and-what-changed)  ยท  [**Setup**](#setup) @@ -232,6 +233,12 @@ fakes for the encoder, reranker, and ArangoDB, so the heavy ML dependencies are never needed just to verify the logic. Optionally `pre-commit install` to run ruff automatically on each commit. +## ๐Ÿ“– Documentation + +- **[Project site](https://vardhjain.github.io/Knowledge_Graph_Question_Answering/)** โ€” the story and results at a glance (GitHub Pages) +- **[Project report (PDF)](docs/Project_Report.pdf)** and **[slides](docs/Graph_RAG_PPT.pptx)** +- **[Architecture diagram](assets/architecture.svg)** ยท **[CHANGELOG](CHANGELOG.md)** ยท **[CONTRIBUTING](CONTRIBUTING.md)** + ## ๐Ÿค Contributing Contributions are welcome โ€” see [CONTRIBUTING.md](CONTRIBUTING.md) for setup, the diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..2faa444 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,10 @@ +# GitHub Pages site (Settings โ†’ Pages โ†’ Source: Deploy from a branch โ†’ main โ†’ /docs) +title: Knowledge Graph Question Answering +description: GraphRAG vs PlainRAG on PubMedQA โ€” a fair, leakage-free, statistically-tested ablation +theme: jekyll-theme-cayman +show_downloads: false + +# Keep the repo's data/binaries out of the built site. +exclude: + - "*.pdf" + - "*.pptx" diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..b278879 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,39 @@ +--- +--- + +[**โ–ถ Live demo**](https://kgqa-ablation.streamlit.app)  ยท  [**GitHub repo**](https://github.com/vardhjain/Knowledge_Graph_Question_Answering)  ยท  [**Project report (PDF)**](https://github.com/vardhjain/Knowledge_Graph_Question_Answering/blob/main/docs/Project_Report.pdf)  ยท  [**Slides**](https://github.com/vardhjain/Knowledge_Graph_Question_Answering/blob/main/docs/Graph_RAG_PPT.pptx) + +## What this is + +Most "GraphRAG beats RAG" demos are confounded โ€” the graph pipeline quietly also +gets a reranker, a different corpus, or even leaks the answer into the prompt. +This project runs a **4-arm ablation** on [PubMedQA](https://pubmedqa.github.io/) +where every layer (corpus, chunking, embedder, reranker, prompt, LLM, top-k, seed) +is held constant, so the accuracy change between adjacent arms is attributable to +exactly one component โ€” verified with a paired **McNemar** test. + +![Architecture and 4-arm ablation](https://raw.githubusercontent.com/vardhjain/Knowledge_Graph_Question_Answering/main/assets/architecture.svg) + +## Results (n = 200, seed 42) + +| Arm | Accuracy | Macro F1 | Adds | +| --- | --- | --- | --- | +| `plain` | 30.0% | 29.7% | baseline chunk RAG | +| `plain_rr` | 37.0% | 35.2% | + cross-encoder reranker | +| **`graph`** | **59.5%** | **50.5%** | + parent-paper expansion | +| `graph_concepts` | 57.5% | 50.0% | + MeSH concept hop | + +![4-arm ablation](https://raw.githubusercontent.com/vardhjain/Knowledge_Graph_Question_Answering/main/results/ablation.png) + +**The honest finding:** the graph's decisive, statistically significant win comes +from **parent-document expansion** (`plain_rr โ†’ graph`: **+22.5 pp**, McNemar +**p < 0.0001**). The reranker helps but isn't significant (+7 pp, p = 0.08), and +MeSH concept-hop expansion does **not** help on this single-abstract dataset +(โˆ’2 pp, p = 0.69) while costing ~5ร— the latency. The graph helps by *deepening* +context, not by *broadening* it. + +## Explore + +- **[Live results dashboard](https://kgqa-ablation.streamlit.app)** โ€” interactive bars, significance tests, per-class breakdown +- **[Source code & README](https://github.com/vardhjain/Knowledge_Graph_Question_Answering)** โ€” package, scripts, tests, CI +- **[Project report (PDF)](https://github.com/vardhjain/Knowledge_Graph_Question_Answering/blob/main/docs/Project_Report.pdf)** and **[slides](https://github.com/vardhjain/Knowledge_Graph_Question_Answering/blob/main/docs/Graph_RAG_PPT.pptx)** From 4a6192d72d76cb1eb058d4863d82ef14c46c8424 Mon Sep 17 00:00:00 2001 From: vardhjain Date: Tue, 23 Jun 2026 12:40:34 -0400 Subject: [PATCH 20/23] Add dashboard screenshot; fix dashboard bar chart + add latency view - assets/dashboard.png: real screenshot of the running dashboard, embedded (clickable) in the README Live demo section - dashboard: group the accuracy/macro-F1 bars (were misleadingly stacked) and replace the redundant figure with a horizontal latency-by-arm chart - bump deploy pin to streamlit>=1.39 (grouped/horizontal bar options) --- README.md | 2 ++ app/dashboard.py | 7 +++---- app/requirements.txt | 2 +- assets/dashboard.png | Bin 0 -> 285567 bytes 4 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 assets/dashboard.png diff --git a/README.md b/README.md index 0c77c31..73b2baa 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,8 @@ paired McNemar significance tests, latency, and (when raw results are present) per-class confusion matrices. No setup, no login โ€” it reads the committed `results/` artifacts, so it needs no LLM, database, or GPU. +[![Results dashboard](assets/dashboard.png)](https://kgqa-ablation.streamlit.app) + Run the dashboard locally: ```bash diff --git a/app/dashboard.py b/app/dashboard.py index 1000b54..07b5f65 100644 --- a/app/dashboard.py +++ b/app/dashboard.py @@ -105,7 +105,7 @@ def main(): with left: st.subheader("Accuracy & macro-F1 by arm") df = pd.DataFrame(arms).set_index("arm") - st.bar_chart(df[["accuracy", "macro_f1"]]) + st.bar_chart(df[["accuracy", "macro_f1"]], stack=False, color=["#2196F3", "#FF9800"]) st.dataframe( df[["adds", "accuracy", "macro_f1", "avg_latency", "samples"]], use_container_width=True, @@ -120,9 +120,8 @@ def main(): cdf[["contrast", "delta_acc", "gains", "losses", "p_value", "significant"]], use_container_width=True, hide_index=True, ) - fig_path = os.path.join(RESULTS_DIR, "ablation.png") - if os.path.exists(fig_path): - st.image(fig_path, caption="4-arm ablation", use_container_width=True) + st.caption("Latency by arm (seconds / query)") + st.bar_chart(df["avg_latency"], color="#26A69A", horizontal=True) # โ”€โ”€ optional: per-class detail from raw per-sample results โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ raw = load_raw() diff --git a/app/requirements.txt b/app/requirements.txt index 9eee92c..40d73da 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -9,6 +9,6 @@ # Also handy locally โ€” `pip install -r app/requirements.txt` runs just the # dashboard. It needs streamlit + pandas (+ scikit-learn, used lazily for the # per-class confusion matrices when results/{arm}_results.json files are present). -streamlit>=1.30 +streamlit>=1.39 pandas>=2.0 scikit-learn>=1.3 diff --git a/assets/dashboard.png b/assets/dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..2dea607c555df13a18a502aeb462896688be5c6e GIT binary patch literal 285567 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%0Ao*JCK}zEBPVVVmuc5oOBWyI|ILP3PcuA29q2q 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%0AHUwB-_2H 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 Ns(CZ#K8G2(* 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 literal 0 HcmV?d00001 From dee231a3c8b83aa17ef584577e731576376b00c2 Mon Sep 17 00:00:00 2001 From: vardhjain Date: Tue, 23 Jun 2026 13:38:23 -0400 Subject: [PATCH 21/23] Add chat UI screenshot; fix ChatInterface for current Gradio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assets/chat.png: the Gradio chat interface (title, description, example questions), embedded in the README chat section - chat_app.py: drop the 'theme' kwarg โ€” gr.ChatInterface no longer accepts it on Gradio 5/6 (requirements pin gradio>=4), so a fresh install would have crashed --- README.md | 2 ++ app/chat_app.py | 1 - assets/chat.png | Bin 0 -> 95743 bytes 3 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 assets/chat.png diff --git a/README.md b/README.md index 73b2baa..5da0257 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,8 @@ pip install -r requirements-app.txt python app/chat_app.py --share # public Gradio link ``` +![GraphRAG chat interface](assets/chat.png) + A hosted always-on chat isn't provided on purpose โ€” it would need a paid GPU and a persistent ArangoDB. See [app/README.md](app/README.md) for details. diff --git a/app/chat_app.py b/app/chat_app.py index 51a74a8..5c4f235 100644 --- a/app/chat_app.py +++ b/app/chat_app.py @@ -76,7 +76,6 @@ def respond(message, history): "deepseek-r1:8b. Answers cite the source PubMed IDs." ), examples=EXAMPLES, - theme="soft", ).launch(share=args.share, server_port=args.port) diff --git a/assets/chat.png b/assets/chat.png new file mode 100644 index 0000000000000000000000000000000000000000..daef3ccc3d29dc7860cc176082538dfaa3cfae5d GIT binary patch literal 95743 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 z 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 HMcDrVgYYGO literal 0 HcmV?d00001 From cde8767be839c766c426a6c52632e315d7c37c94 Mon Sep 17 00:00:00 2001 From: vardhjain Date: Tue, 23 Jun 2026 18:06:37 -0400 Subject: [PATCH 22/23] Point live-demo links at the deployed Streamlit URL The app deployed to Streamlit's auto-generated subdomain rather than the custom kgqa-ablation; repoint the README badge/nav/screenshot link, the docs site links, and the About website to the live URL so nothing 404s. --- README.md | 8 ++++---- app/README.md | 8 ++++---- docs/index.md | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5da0257..a5c8973 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,10 @@ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Lint: ruff](https://img.shields.io/badge/lint-ruff-261230.svg)](https://github.com/astral-sh/ruff) -[![Live Demo](https://img.shields.io/badge/Streamlit-Live%20Demo-FF4B4B?logo=streamlit&logoColor=white)](https://kgqa-ablation.streamlit.app) +[![Live Demo](https://img.shields.io/badge/Streamlit-Live%20Demo-FF4B4B?logo=streamlit&logoColor=white)](https://vardhjain-knowledge-graph-question-answerin-appdashboard-hkwi57.streamlit.app) [![Docs](https://img.shields.io/badge/docs-online-1f6feb)](https://vardhjain.github.io/Knowledge_Graph_Question_Answering/) -[**โ–ถ Live demo**](https://kgqa-ablation.streamlit.app)  ยท  [**Results**](#results)  ยท  [**Why it's fair**](#why-the-original-comparison-was-unfair-and-what-changed)  ยท  [**Setup**](#setup) +[**โ–ถ Live demo**](https://vardhjain-knowledge-graph-question-answerin-appdashboard-hkwi57.streamlit.app)  ยท  [**Results**](#results)  ยท  [**Why it's fair**](#why-the-original-comparison-was-unfair-and-what-changed)  ยท  [**Setup**](#setup) @@ -144,13 +144,13 @@ once, then [`notebooks/02_benchmark.ipynb`](notebooks/02_benchmark.ipynb) (set ## โ–ถ Live demo -**[โ–ถ Open the results dashboard](https://kgqa-ablation.streamlit.app)** โ€” an +**[โ–ถ Open the results dashboard](https://vardhjain-knowledge-graph-question-answerin-appdashboard-hkwi57.streamlit.app)** โ€” an interactive Streamlit dashboard of the 4-arm ablation: headline accuracy, the paired McNemar significance tests, latency, and (when raw results are present) per-class confusion matrices. No setup, no login โ€” it reads the committed `results/` artifacts, so it needs no LLM, database, or GPU. -[![Results dashboard](assets/dashboard.png)](https://kgqa-ablation.streamlit.app) +[![Results dashboard](assets/dashboard.png)](https://vardhjain-knowledge-graph-question-answerin-appdashboard-hkwi57.streamlit.app) Run the dashboard locally: diff --git a/app/README.md b/app/README.md index a9e4fab..a70c7d7 100644 --- a/app/README.md +++ b/app/README.md @@ -43,10 +43,10 @@ entrypoint's directory before the heavy root `requirements.txt`). 3. **Create app โ†’ Deploy a public app from GitHub.** 4. Repository `vardhjain/Knowledge_Graph_Question_Answering`, Branch `main`, **Main file path `app/dashboard.py`**. -5. Advanced settings โ†’ Python 3.11; set the subdomain to `kgqa-ablation` - (matches the README badge โ†’ `https://kgqa-ablation.streamlit.app`). -6. **Deploy.** Copy the final URL; if you changed the subdomain, update the - badge + "Live demo" link in the root README. +5. (Optional) Advanced settings โ†’ Python 3.11. Set a custom subdomain (e.g. + `kgqa-ablation`) for a clean URL, or accept the auto-generated one. +6. **Deploy.** Copy the final `*.streamlit.app` URL and point the badge + + "Live demo" link in the root README at it. > Tip: commit the per-sample `results/{arm}_results.json` files too (if you still > have them from the benchmark run) to light up the confusion-matrix section. diff --git a/docs/index.md b/docs/index.md index b278879..d01bc0a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ --- --- -[**โ–ถ Live demo**](https://kgqa-ablation.streamlit.app)  ยท  [**GitHub repo**](https://github.com/vardhjain/Knowledge_Graph_Question_Answering)  ยท  [**Project report (PDF)**](https://github.com/vardhjain/Knowledge_Graph_Question_Answering/blob/main/docs/Project_Report.pdf)  ยท  [**Slides**](https://github.com/vardhjain/Knowledge_Graph_Question_Answering/blob/main/docs/Graph_RAG_PPT.pptx) +[**โ–ถ Live demo**](https://vardhjain-knowledge-graph-question-answerin-appdashboard-hkwi57.streamlit.app)  ยท  [**GitHub repo**](https://github.com/vardhjain/Knowledge_Graph_Question_Answering)  ยท  [**Project report (PDF)**](https://github.com/vardhjain/Knowledge_Graph_Question_Answering/blob/main/docs/Project_Report.pdf)  ยท  [**Slides**](https://github.com/vardhjain/Knowledge_Graph_Question_Answering/blob/main/docs/Graph_RAG_PPT.pptx) ## What this is @@ -34,6 +34,6 @@ context, not by *broadening* it. ## Explore -- **[Live results dashboard](https://kgqa-ablation.streamlit.app)** โ€” interactive bars, significance tests, per-class breakdown +- **[Live results dashboard](https://vardhjain-knowledge-graph-question-answerin-appdashboard-hkwi57.streamlit.app)** โ€” interactive bars, significance tests, per-class breakdown - **[Source code & README](https://github.com/vardhjain/Knowledge_Graph_Question_Answering)** โ€” package, scripts, tests, CI - **[Project report (PDF)](https://github.com/vardhjain/Knowledge_Graph_Question_Answering/blob/main/docs/Project_Report.pdf)** and **[slides](https://github.com/vardhjain/Knowledge_Graph_Question_Answering/blob/main/docs/Graph_RAG_PPT.pptx)** From d3b2f35a069f9adc7b8b2d9b89ddff053396df08 Mon Sep 17 00:00:00 2001 From: vardhjain Date: Tue, 23 Jun 2026 18:12:15 -0400 Subject: [PATCH 23/23] CI: pass CODECOV_TOKEN and bump codecov-action to v5 Codecov rejected tokenless uploads ('Token required - not valid tokenless upload'), so the badge stayed unknown. Use the repo upload token via the CODECOV_TOKEN secret. --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f0a6c8..410f499 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,8 @@ jobs: - name: Upload coverage to Codecov if: matrix.python-version == '3.11' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: + token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage.xml fail_ci_if_error: false