This is a read-only view of the notebook source: text and code, no outputs. To run it, open the live marimo server when the workstation tunnel is up, or run uv run marimo edit notebooks/cs224w/L16_llms_gnns.py.
import warnings warnings.filterwarnings("ignore", category=FutureWarning) # harmless Intel-GPU probe message printed by torch on this machine warnings.filterwarnings("ignore", message=".*Level Zero.*") import hashlib import json import threading import time from concurrent.futures import ThreadPoolExecutor import urllib.request import marimo as mo from gl.nb import reveal import numpy as np import pandas as pd import networkx as nx import matplotlib.pyplot as plt import torch import torch.nn as nn import torch.nn.functional as F from networkx.algorithms.approximation import steiner_tree from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from torch_geometric.nn import GCNConv from torch_geometric.utils import from_networkx SEED = 0 rng = np.random.default_rng(SEED) torch.manual_seed(SEED) # several open notebooks share the CPU; more threads = contention torch.set_num_threads(4) # shared datasets (Cora etc. are cached here) and this folder's disk cache DATA_DIR = mo.notebook_dir().parents[1] / "data" CACHE_DIR = mo.notebook_dir() / "cache" CACHE_DIR.mkdir(exist_ok=True) plt.rcParams.update( { "figure.dpi": 100, "axes.spines.top": False, "axes.spines.right": False, } )
L16 · LLMs + GNNs
CS224W Machine Learning with Graphs · hands-on companion to Lecture 16 (guest lecture, NVIDIA PyG team)
Audience: you did L03–L05 and L10 (KGs); you have used an LLM chat model. Prerequisites: TF-IDF / text embeddings at a "have heard of it" level.
By the end you can:
-
turn text into a knowledge graph and node features, and compare VectorRAG with GraphRAG retrieval (k-NN seeds + neighbourhood expansion + a prize-collecting-Steiner-style tree) on multi-hop questions;
-
train a small GNN reader on the retrieved subgraph (the "GNN feeds LLM" half of G-Retriever) and see why it beats reading the raw edge list;
-
do the reverse, "LLM feeds GNN": text features from node descriptions, then a GNN on top (GLEM's setting);
- optionally ask a local Ollama LLM the same questions with different contexts (cached; skipped if no server is reachable).
In short. An LLM is like an associative memory: strong in distribution, prone to hallucinate outside it, and non-deterministic. A GNN with \(N\) layers performs \(N\) hops of relational "logic", deterministically, with few parameters. GNN → LLM (question answering): retrieve a relevant subgraph, encode it with a GNN, give it to the LLM (G-Retriever, a form of GraphRAG). LLM → GNN (graph tasks on text-attributed graphs): turn node text into features, train a GNN (GLEM).
Outline
- A company knowledge graph (synthetic) — people with short bios and typed relations (works_with, reports_to, mentors).
- VectorRAG vs. GraphRAG — which retrieval finds the facts a two-hop question needs?
- GNN feeds LLM — a GNN reader that answers from the retrieved subgraph.
- LLM feeds GNN — text features + graph for classifying people's departments.
- Optional: ask a local LLM (Ollama), with cached answers.
- Exercises, research links.
1 · A company knowledge graph
Generated here: 150 employees in 5 departments. Each has a one-line bio, works with a few colleagues (mostly in their own department), reports to their department lead, and some mentor others. The lecture's worked example: "Who works with both Bob Joe and Ronald Coleyman?", a two-hop join that a reader of raw text must perform itself.
FIRST = [ · 116 lines
FIRST = [ "Bob", "Arno", "Ronald", "Richard", "Sara", "Mina", "Leila", "Omid", "Nora", "Ali", "Hana", "Reza", "Tara", "Kian", "Dara", "Lena", "Yara", "Ivan", "Maya", "Noah", "Zane", "Iris", "Elif", "Omar", "Ravi", ] LAST = [ "Joe", "Swarzfinger", "Coleyman", "Preor", "Karimi", "Novak", "Haddad", "Silva", "Moreau", "Tanaka", "Okafor", "Larsen", "Rossi", "Weber", "Costa", "Ahmadi", ] DEPTS = ["research", "sales", "finance", "engineering", "legal"] TOPICS = { "research": ["experiments", "papers", "models", "hypotheses"], "sales": ["clients", "deals", "pipeline", "quotas"], "finance": ["budgets", "audits", "forecasts", "invoices"], "engineering": ["servers", "code", "deployments", "APIs"], "legal": ["contracts", "compliance", "disputes", "policies"], } def make_company(n=150, seed=SEED): r = np.random.default_rng(seed) names = [] while len(names) < n: nm = f"{r.choice(FIRST)} {r.choice(LAST)}" if nm not in names: names.append(nm) dept = r.integers(0, 5, n) g = nx.MultiDiGraph() for i, nm in enumerate(names): # bios mention the department's topics, but 30% of people mention a *random* department's topics (noisy text) topic_dept = dept[i] if r.random() > 0.3 else r.integers(0, 5) words = r.choice(TOPICS[DEPTS[topic_dept]], 2, replace=False) g.add_node( i, name=nm, dept=int(dept[i]), bio=f"{nm} enjoys working on {words[0]} and {words[1]}.", ) leads = {d: int(np.flatnonzero(dept == d)[0]) for d in range(5)} for i in range(n): if i != leads[dept[i]]: g.add_edge(i, leads[dept[i]], rel="reports_to") for _ in range(r.integers(1, 4)): same = r.random() < 0.8 pool = ( np.flatnonzero(dept == dept[i]) if same else np.arange(n) ) j = int(r.choice(pool)) if j != i and not g.has_edge(i, j) and not g.has_edge(j, i): g.add_edge(i, j, rel="works_with") if r.random() < 0.3: j = int(r.integers(n)) if j != i: g.add_edge(i, j, rel="mentors") return g company = make_company() works_with = nx.Graph( [ (u, v) for u, v, d in company.edges(data=True) if d["rel"] == "works_with" ] ) facts = [ f"{company.nodes[u]['name']} {d['rel'].replace('_', ' ')} {company.nodes[v]['name']}." for u, v, d in company.edges(data=True) ] mo.vstack( [ mo.md( f"**{company.number_of_nodes()} people, {company.number_of_edges()} typed edges** " f"({pd.Series([d['rel'] for *_, d in company.edges(data=True)]).value_counts().to_dict()}). Some facts as text:" ), mo.md( "\n".join(f"- {f}" for f in facts[:6]) + "\n\nSome bios:\n\n" + "\n".join(f"- {company.nodes[i]['bio']}" for i in range(3)) ), ] )
def make_questions(n_q=150, seed=SEED): """Two-hop questions: 'Who works with both X and Z?' where exactly one person Y links them.""" r = np.random.default_rng(seed) qs = [] nodes = list(works_with.nodes) while len(qs) < n_q: y = int(r.choice(nodes)) nb = list(works_with.neighbors(y)) if len(nb) < 2: continue x, z = (int(v) for v in r.choice(nb, 2, replace=False)) common = set(works_with.neighbors(x)) & set( works_with.neighbors(z) ) if common == {y} and not works_with.has_edge(x, z): nm = company.nodes qs.append( { "x": x, "z": z, "answer": y, "question": f"Who works with both {nm[x]['name']} and {nm[z]['name']}?", } ) return ( pd.DataFrame(qs) .drop_duplicates(subset=["x", "z"]) .reset_index(drop=True) ) questions = make_questions() mo.vstack( [ mo.md( f"**{len(questions)} two-hop questions**, each with exactly one correct answer. Examples:" ), questions.head(4).assign( answer_name=lambda d: [ company.nodes[a]["name"] for a in d.answer ] ), ] )
2 · VectorRAG vs. GraphRAG
-
VectorRAG: embed every fact sentence (here TF-IDF, standing in for a sentence-transformer), embed the question, retrieve the top-\(k\) most similar facts. Facts are scored one at a time.
-
GraphRAG: (1) seed entities by k-NN between the question and the node names; (2) expand to their neighbourhood; (3) prune to a small connected subgraph linking the seeds: a Steiner tree, the zero-cost special case of the prize-collecting Steiner tree (PCST) that G-Retriever uses.
A question is answerable from the context only if both supporting facts ("X works with Y", "Y works with Z") are retrieved.
_edge_list = [(u, v, d["rel"]) for u, v, d in company.edges(data=True)] · 69 lines
_edge_list = [(u, v, d["rel"]) for u, v, d in company.edges(data=True)] fact_vectorizer = TfidfVectorizer().fit(facts + list(questions.question)) fact_matrix = fact_vectorizer.transform(facts) name_vectorizer = TfidfVectorizer( analyzer="char_wb", ngram_range=(2, 4) ).fit([company.nodes[i]["name"] for i in company]) name_matrix = name_vectorizer.transform( [company.nodes[i]["name"] for i in company] ) def supporting(q): """Indices of the two facts that answer the question (works_with in either direction).""" need = {frozenset((q.x, q.answer)), frozenset((q.answer, q.z))} return { i for i, (u, v, rel) in enumerate(_edge_list) if rel == "works_with" and frozenset((u, v)) in need } def vector_rag(q, k): sims = ( (fact_matrix @ fact_vectorizer.transform([q.question]).T) .toarray() .ravel() ) return set(np.argsort(-sims)[:k]) def seed_entities(question, n_seeds=2): """k-NN between the question's name-like spans and node names (char n-grams).""" text = question.replace("Who works with both ", "").rstrip("?") spans = [s.strip() for s in text.split(" and ")] sims = (name_matrix @ name_vectorizer.transform(spans).T).toarray() return [int(np.argmax(sims[:, j])) for j in range(len(spans))][ :n_seeds ] def graph_rag(q, mode): seeds = seed_entities(q.question) if mode == "2-hop expansion": nodes = set(seeds) for s in seeds: nodes |= set( nx.single_source_shortest_path_length( company.to_undirected(as_view=True), s, cutoff=2 ) ) keep = { i for i, (u, v, _) in enumerate(_edge_list) if u in nodes and v in nodes } else: # Steiner tree linking the seeds tree = ( steiner_tree(works_with, seeds) if all(s in works_with for s in seeds) and nx.has_path(works_with, *seeds) else nx.Graph() ) keep = { i for i, (u, v, rel) in enumerate(_edge_list) if rel == "works_with" and tree.has_edge(u, v) } return keep, seeds rag_k = mo.ui.slider( 5, 60, step=5, value=10, label="VectorRAG top-k facts", show_value=True ) rag_k
_rows = [] for _name, _fn in [ ( f"VectorRAG (top-{rag_k.value})", lambda q: vector_rag(q, rag_k.value), ), ( "GraphRAG: seeds + 2-hop expansion", lambda q: graph_rag(q, "2-hop expansion")[0], ), ( "GraphRAG: seeds + Steiner tree (PCST-lite)", lambda q: graph_rag(q, "steiner")[0], ), ]: _ok, _size = [], [] for _q in questions.itertuples(): _ctx = _fn(_q) _ok.append(supporting(_q) <= _ctx) _size.append(len(_ctx)) _rows.append( { "retriever": _name, "questions answerable from context": np.mean(_ok), "facts in context (mean)": np.mean(_size), } ) _seed_acc = np.mean( [ set(seed_entities(q.question)) == {q.x, q.z} for q in questions.itertuples() ] ) mo.vstack( [ pd.DataFrame(_rows).round(3), mo.md( f"Seed-entity resolution accuracy (both names found): **{_seed_acc:.3f}**" ), ] )
Try it: at the default \(k=10\), VectorRAG retrieves both supporting facts for only 46% of questions. The question mentions X and Z, so the facts about X and Z score high, but the bridge person Y is never named, and Y's facts compete with every other fact about X and Z. It needs \(k\approx30\) to cover all questions. GraphRAG's 2-hop expansion always contains the answer but drags in ≈ 150 facts. The Steiner tree keeps exactly the 2 facts that matter (X–Y–Z): high prize, low cost. Seed resolution by name k-NN was perfect here, as names are unique and spelled exactly.
3 · GNN feeds LLM: a GNN reader on the retrieved subgraph
G-Retriever encodes the retrieved subgraph with a GNN and hands the pooled embedding to the LLM, which decodes the answer. Here the "decoder" is replaced by a simple node-selection head, so we can train it in seconds: mark the two seed nodes (the labelling trick), run a 2-layer GNN over the retrieved 2-hop subgraph (the big one, not the Steiner tree, so the GNN must do the join), and pick the highest-scoring node. Train on 100 questions, test on the rest.
_undirected = company.to_undirected(as_view=False) · 83 lines
_undirected = company.to_undirected(as_view=False) _all_nodes = sorted(company.nodes) def subgraph_example(q): """2-hop subgraph around the seeds as a PyG-style tuple (x, edge_index, nodes, answer position).""" seeds = seed_entities(q.question) nodes = set(seeds) for s in seeds: nodes |= set( nx.single_source_shortest_path_length(_undirected, s, cutoff=2) ) nodes = sorted(nodes) pos = {v: i for i, v in enumerate(nodes)} rel_ids = {"works_with": 0, "reports_to": 1, "mentors": 2} src, dst, et = [], [], [] for u, v, d in company.edges(data=True): if u in pos and v in pos: src += [pos[u], pos[v]] dst += [pos[v], pos[u]] et += [rel_ids[d["rel"]]] * 2 x = torch.zeros(len(nodes), 2) for s in seeds: x[pos[s], 0] = 1.0 # labelling trick: "I am a seed" x[:, 1] = 1.0 return ( x, torch.tensor([src, dst]), torch.tensor(et), nodes, pos.get(q.answer, -1), ) class GNNReader(nn.Module): """Relation-aware 2-layer GNN (one weight per relation type) + node-scoring head.""" def __init__(self, d=32, n_rel=3): super().__init__() self.inp = nn.Linear(2, d) self.W = nn.ModuleList( nn.ModuleList( nn.Linear(d, d, bias=False) for _ in range(n_rel) ) for _ in range(2) ) self.self_w = nn.ModuleList(nn.Linear(d, d) for _ in range(2)) self.score = nn.Linear(d, 1) def forward(self, x, edge_index, edge_type): h = F.relu(self.inp(x)) for W, S in zip(self.W, self.self_w): agg = torch.zeros_like(h) for r, lin in enumerate(W): m = edge_type == r agg = agg.index_add( 0, edge_index[1, m], lin(h[edge_index[0, m]]) ) h = F.relu(S(h) + agg) return self.score(h).squeeze(1) reader_data = [subgraph_example(q) for q in questions.itertuples()] torch.manual_seed(SEED) reader = GNNReader() _opt = torch.optim.Adam(reader.parameters(), lr=0.01) _train, _test = reader_data[:100], reader_data[100:] for _ in range(60): for _x, _ei, _et, _nodes, _ans in _train: _opt.zero_grad() F.cross_entropy( reader(_x, _ei, _et)[None], torch.tensor([_ans]) ).backward() _opt.step() with torch.no_grad(): reader_acc = np.mean( [ int(reader(_x, _ei, _et).argmax()) == _ans for _x, _ei, _et, _nodes, _ans in _test ] ) _sizes = [len(n) for *_, n, _ in reader_data] mo.md( f"GNN reader on 2-hop subgraphs ({np.mean(_sizes):.0f} nodes on average): test accuracy **{reader_acc:.3f}** " f"on {len(_test)} unseen questions (random guess ≈ {np.mean([1 / s for s in _sizes]):.3f})." )
The GNN reader answers all 42 held-out questions correctly from ≈ 61-node subgraphs (random pick: ≈ 0.02). Two message-passing layers are the two-hop join: the seed flags travel one hop each way and meet at the bridge person. This is the lecture's argument: the GNN contributes deterministic relational "logic" cheaply; the LLM contributes language.
4 · Ask a real LLM (Ollama)
Now the full GraphRAG loop with an actual LLM on an Ollama server (set GL_OLLAMA_URL). Same questions, four contexts:
no context, all 450 facts as raw text (the LLM must do the join itself), VectorRAG top-10, and GraphRAG Steiner tree.
Answers are cached in notebooks/cs224w/cache/llm_cache.json, so re-runs make no network calls.
import os · 86 lines
import os # Point GL_OLLAMA_URL at your Ollama server (e.g. the team reverse proxy); defaults to a local Ollama. OLLAMA_URL = os.environ.get("GL_OLLAMA_URL", "http://localhost:11434") LLM_CACHE = CACHE_DIR / "llm_cache.json" _cache_lock = threading.Lock() def _load_cache(): try: return json.loads(LLM_CACHE.read_text()) except (FileNotFoundError, json.JSONDecodeError): return {} def ask_llm(prompt, model, timeout=180): """One chat call to Ollama, cached on disk by (model, prompt); thread-safe. Returns the answer (or an error string).""" key = hashlib.sha1(f"{model}\n{prompt}".encode()).hexdigest() with _cache_lock: cached = _load_cache().get(key) if cached is not None: return cached body = json.dumps( { "model": model, "messages": [{"role": "user", "content": prompt}], "stream": False, "think": False, "options": {"temperature": 0}, } ).encode() try: req = urllib.request.Request( f"{OLLAMA_URL}/api/chat", data=body, headers={"Content-Type": "application/json"}, ) answer = json.loads( urllib.request.urlopen(req, timeout=timeout).read() )["message"]["content"].strip() except Exception as e: # unreachable server etc.: don't cache return f"[LLM error: {e}]" with _cache_lock: cache = _load_cache() cache[key] = answer tmp = LLM_CACHE.with_suffix(".tmp") tmp.write_text(json.dumps(cache)) tmp.replace(LLM_CACHE) return answer def server_status(): try: tags = json.loads( urllib.request.urlopen( f"{OLLAMA_URL}/api/tags", timeout=5 ).read() ) return [m["name"] for m in tags["models"]] except Exception: return [] _models = server_status() llm_model = mo.ui.dropdown( _models or ["gemma4:latest"], value="gemma4:latest" if "gemma4:latest" in _models or not _models else _models[0], label="LLM", ) llm_n_questions = mo.ui.slider( 10, 42, step=4, value=30, label="test questions", show_value=True ) llm_run = mo.ui.run_button( label="Ask the LLM (≈ 1–3 min uncached, instant when cached)" ) mo.vstack( [ mo.md( f"Server `{OLLAMA_URL}`: " + ( f"reachable, {len(_models)} models." if _models else "**not reachable**; cached answers (if any) still work." ) ), mo.hstack([llm_model, llm_n_questions, llm_run], justify="start"), ] )
def context_text(q, kind): · 66 lines
def context_text(q, kind): if kind == "no context": return "" if kind == "all facts (raw text)": return "\n".join(facts) if kind == "VectorRAG top-10": return "\n".join(facts[i] for i in sorted(vector_rag(q, 10))) if kind == "GraphRAG Steiner tree": return "\n".join( facts[i] for i in sorted(graph_rag(q, "steiner")[0]) ) LLM_CONTEXTS = [ "no context", "all facts (raw text)", "VectorRAG top-10", "GraphRAG Steiner tree", ] def llm_prompt(q, kind): ctx = context_text(q, kind) return ( (f"Facts about a company:\n{ctx}\n\n" if ctx else "") + f"Question: {q.question}\nAnswer with the person's full name only. If you cannot know, answer 'unknown'." ) mo.stop(not llm_run.value, mo.md("Press **Ask the LLM**.")) _qs = list(questions.iloc[100 : 100 + llm_n_questions.value].itertuples()) _jobs = [(q, k) for q in _qs for k in LLM_CONTEXTS] _t = time.time() with ThreadPoolExecutor(8) as _pool: _answers = list( _pool.map( lambda job: ask_llm(llm_prompt(*job), llm_model.value), _jobs ) ) llm_answers = pd.DataFrame( [ { "question": q.question, "context": k, "gold": company.nodes[q.answer]["name"], "llm": a, "correct": company.nodes[q.answer]["name"].lower() in a.lower(), } for (q, k), a in zip(_jobs, _answers) ] ) _summary = ( llm_answers.groupby("context", sort=False) .correct.mean() .rename("accuracy") .to_frame() ) _summary["context size (facts)"] = [0, len(facts), 10, 2] mo.vstack( [ mo.md( f"{llm_model.value}: {len(_jobs)} calls in {time.time() - _t:.0f} s" ), _summary.round(3), mo.accordion({"all answers": llm_answers}), ] )
_rev = reveal( · 33 lines
_rev = reveal( r""" Measured on 30 held-out questions (answers cached, temperature 0): | context | facts given | gemma4 (8B) | gemma4:26b | |---|---|---|---| | no context | 0 | 0.00 | 0.00 | | all facts, raw text | 450 | 0.10 | 0.50 | | VectorRAG top-10 | 10 | 0.03 | 0.37 | | **GraphRAG Steiner tree** | **2** | **0.97** | **1.00** | * Without context both models (correctly) say "unknown": the company is fictional. * Given **all** the facts, the LLM must perform the join itself. The 8B model mostly gives up; the 26B model gets half right. * VectorRAG is worse than raw text here: its top-10 often misses the bridge fact (§2), and even when both facts are present they sit among 8 distractors. * The **2-fact Steiner-tree context** makes the question trivial for both: 0.97 / 1.00 with a 225× shorter prompt. Structure-aware retrieval does the reasoning, so a small, cheap LLM suffices. This is the lecture's STaRK-Prime point: an 8B model plus a GNN beat agentic GraphRAG with much larger models. """, predict="Rank the four contexts (none, all 450 facts, VectorRAG top-10, a 2-fact Steiner tree) for the small and the large LLM.", ) _post = mo.md(r""" **Try it:** pick another model in the dropdown and press the button again (new answers are cached too). ## 5 · LLM feeds GNN: text-attributed graphs The reverse direction: an LLM (or any text encoder) turns each node's **text** into features, and a GNN propagates them. Task: predict each employee's **department** from their bio (which is misleading for 30% of people, by construction) and the graph. Two text encoders: TF-IDF of the bio, and an **LLM zero-shot guess** ("which department does this bio suggest?", one-hot, cached). Each is used alone (logistic regression) and as input features to a 2-layer GCN over works_with + reports_to edges. """) mo.vstack([_rev, _post])
def llm_department_guess(bio, model="gemma4:latest"): prompt = ( f"Bio: {bio}\nWhich department does this person most likely work in? " f"Choose exactly one of: {', '.join(DEPTS)}. Answer with the single word only." ) ans = ask_llm(prompt, model).lower() return next((i for i, d in enumerate(DEPTS) if d in ans), -1) dept_y = torch.tensor( [company.nodes[i]["dept"] for i in sorted(company.nodes)] ) employee_bios = [company.nodes[i]["bio"] for i in sorted(company.nodes)] text_tfidf = torch.tensor( TfidfVectorizer().fit_transform(employee_bios).toarray(), dtype=torch.float, ) glem_run = mo.ui.run_button( label="Get LLM department guesses for 150 bios (cached) and train" ) glem_run
mo.stop( · 78 lines
mo.stop( not glem_run.value, mo.md( "Press the button (150 short LLM calls the first time, ≈ 30 s)." ), ) with ThreadPoolExecutor(8) as _pool: _guess = list(_pool.map(llm_department_guess, employee_bios)) text_llm = torch.zeros(len(employee_bios), 5) for _i, _g in enumerate(_guess): if _g >= 0: text_llm[_i, _g] = 1.0 _g_und = nx.Graph( [ (u, v) for u, v, d in company.edges(data=True) if d["rel"] in ("works_with", "reports_to") ] ) _ei = torch.tensor( [[u, v] for u, v in _g_und.edges] + [[v, u] for u, v in _g_und.edges] ).t() _perm = torch.randperm( len(employee_bios), generator=torch.Generator().manual_seed(SEED) ) _tr = torch.zeros(len(employee_bios), dtype=torch.bool) _tr[_perm[:75]] = True def _fit_gcn(x): torch.manual_seed(SEED) c1, c2 = GCNConv(x.size(1), 32), GCNConv(32, 5) opt = torch.optim.Adam( list(c1.parameters()) + list(c2.parameters()), lr=0.01, weight_decay=5e-4, ) for _ in range(200): opt.zero_grad() out = c2(F.relu(c1(x, _ei)), _ei) F.cross_entropy(out[_tr], dept_y[_tr]).backward() opt.step() with torch.no_grad(): return ( (c2(F.relu(c1(x, _ei)), _ei).argmax(1)[~_tr] == dept_y[~_tr]) .float() .mean() .item() ) def _fit_text(x): clf = LogisticRegression(max_iter=2000).fit( x[_tr].numpy(), dept_y[_tr].numpy() ) return clf.score(x[~_tr].numpy(), dept_y[~_tr].numpy()) glem_results = pd.DataFrame( [ { "text encoder": "TF-IDF of bio", "text only (logistic)": _fit_text(text_tfidf), "text → GCN": _fit_gcn(text_tfidf), }, { "text encoder": "LLM zero-shot department guess", "text only (logistic)": _fit_text(text_llm), "text → GCN": _fit_gcn(text_llm), }, ] ) mo.vstack( [ mo.md( f"LLM guess = true department for **{np.mean([g == int(y) for g, y in zip(_guess, dept_y)]):.2f}** of people " "(bios are misleading for ≈ 30% by construction). Test accuracy on 75 held-out people:" ), glem_results.round(3), ] )
_rev = reveal( · 26 lines
_rev = reveal( r""" Measured (75 held-out employees): from text alone, TF-IDF reaches **0.81** and the LLM's zero-shot guesses **0.83** (the LLM is right for 82% of bios; the misleading ones fool both). Feed either text representation into a **GCN over the org graph** and accuracy rises to **1.00 / 0.99**: colleagues and managers correct the misleading bios. That is the LLM → GNN direction: language models read the text, the GNN reads the structure. GLEM goes further and **co-trains** them, with the GNN's predictions as pseudo-labels to fine-tune the LM and vice versa. """, predict="Text alone vs. text + a GCN over the org graph: how much can colleagues and managers correct misleading bios?", ) _post = mo.md(r""" --- ## 6 · Exercises **Exercise 1 (context budget).** How many times shorter is the Steiner-tree context than the "all facts" context, in facts? → `ex1_ratio`. **Exercise 2 (better VectorRAG).** Write `split_query_rag(q, k)`: retrieve the top-$k$ facts for **each** name in the question separately (two TF-IDF queries, e.g. just the name string) and return the union of fact indices. With $k=8$ per name (≤ 16 facts) it should make ≥ 90% of the questions answerable, versus 46% for plain top-10. (Use `fact_vectorizer`, `fact_matrix`, `seed_entities` or plain string splitting.) **Exercise 3 (think).** Why can't an LLM given the raw edge list be "fixed" just by giving it a longer context window? Write your answer down, then open the solution. """) mo.vstack([_rev, _post])
ex1_ratio = None · 4 lines
ex1_ratio = None def split_query_rag(q, k): return None
def _verdict(ok, answered): · 64 lines
def _verdict(ok, answered): if not answered: return mo.callout(mo.md("Not answered yet."), kind="neutral") return mo.callout( mo.md("Correct!" if ok else "Not yet — try again or open a hint."), kind="success" if ok else "warn", ) def _check_split(): try: q0 = next(questions.itertuples()) if split_query_rag(q0, 8) is None: return _verdict(False, False) rate = np.mean( [ supporting(q) <= set(split_query_rag(q, 8)) for q in questions.itertuples() ] ) return mo.callout( mo.md( f"Answerable: **{rate:.2f}** of questions with ≤ 16 facts. " + ("Correct!" if rate >= 0.9 else "Aim for ≥ 0.90.") ), kind="success" if rate >= 0.9 else "warn", ) except Exception as e: return mo.callout(mo.md(f"Error: `{e}`"), kind="danger") mo.vstack( [ mo.md("**Exercise 1**"), _verdict( ex1_ratio is not None and abs(ex1_ratio - len(facts) / 2) < 1e-6, ex1_ratio is not None, ), mo.md("**Exercise 2**"), _check_split(), mo.accordion( { "Solution 1": mo.md( f"{len(facts)} / 2 = **{len(facts) / 2:.0f}×**." ), "Solution 2": mo.md("""```python def split_query_rag(q, k): names = q.question.replace("Who works with both ", "").rstrip("?").split(" and ") out = set() for name in names: sims = (fact_matrix @ fact_vectorizer.transform([name]).T).toarray().ravel() out |= set(np.argsort(-sims)[:k]) return out ``` Each entity gets its own budget, so the bridge facts are no longer crowded out by the other name. This is a small step towards entity-centric (graph) retrieval."""), "Solution 3": mo.md( "The facts *are* all there with a long context (0.10 / 0.50 accuracy above). The bottleneck is doing the **join** " "reliably inside the model: finding X's partners, Z's partners and intersecting them among hundreds of lines. " "Structure-aware retrieval (or a GNN) performs the join explicitly and hands the LLM only the result." ), } ), ] )
7 · Connecting to your research (SNA on X/Twitter)
-
Questions over your tweet database ("which accounts amplified both X and Y?", "who links camp A to camp B?") are multi-hop joins. Retrieve a small subgraph (seeds = mentioned accounts/hashtags; Steiner tree / PCST over the retweet graph) and give that to the LLM, not raw tweet dumps. Your team's gemma4 handled 2-fact contexts almost perfectly here.
-
LLM → GNN for tagging: your
usa-activepipeline already gets gemma4 labels for tweets. Using those labels as node features and letting a GNN propagate over the retweet graph should fix many per-tweet mistakes (§5: 0.83 → 0.99 here). Cache the LLM calls exactly asask_llmdoes.
Summary
- GraphRAG = seed entities (k-NN) → expand → prune (PCST / Steiner tree). On two-hop questions: 100% of contexts answerable with 2 facts, vs 46% for VectorRAG top-10.
- With a real LLM: gemma4 0.97 (26b: 1.00) with the Steiner context vs 0.10 (0.50) with all 450 facts and 0.03 (0.37) with VectorRAG.
- A 2-layer GNN reader solves the join exactly (1.00 on held-out questions).
- LLM → GNN: text features (TF-IDF or LLM zero-shot guesses) + a GCN fix misleading text (0.81–0.83 → 0.99–1.00).
Next — L17 · Agents + graphs: RLHF and BPR, STaRK, AvaTaR, CollabLLM.