Research-Stack/4-Infrastructure/shim/snap_pist_spectral_crossval.py
allaun 7b0c0a363b feat(infra): citation crawler and spectral cross-validation research shims
Add exploratory Python shims for:
- OpenAlex citation crawling
- Semantic Scholar citation crawling
- PageRank eigenvalue survey
- SNAP PIST spectral cross-validation
- Fiedler vector validation
2026-06-21 01:04:19 -05:00

444 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
snap_pist_spectral_crossval — Cross-validate SNAP citation graph through PIST
matrix projection vs spectral survey.
Pipeline:
1. Load SNAP edge list → NetworkX DiGraph
2. (Optionally) sample subgraph for spectral analysis
3. Serialize each paper's citation neighborhood as text
4. Run PIST token→strand 8×8 matrix projection
5. Run spectral survey (PageRank, HITS, Fiedler)
6. Cross-tabulate PIST matrix hashes vs spectral bin assignments
7. Emit comparison receipt JSON
Claim boundary: cross-validation comparison only; no classifier output.
"""
import gzip
import hashlib
import json
import math
import os
import random
import re
import sys
from collections import defaultdict, Counter
import networkx as nx
import numpy as np
N_STRANDS = 8
random.seed(42)
np.random.seed(42)
# ═══════════════════════════════════════════════════════════════════════
# §1 SNAP PARSING
# ═══════════════════════════════════════════════════════════════════════
def load_snap_edgelist(path: str) -> nx.DiGraph:
g = nx.DiGraph()
with gzip.open(path, "rt") if path.endswith(".gz") else open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) < 2:
continue
g.add_edge(int(parts[0]), int(parts[1]))
return g
# ═══════════════════════════════════════════════════════════════════════
# §2 CITATION CONTEXT TEXT + PIST TOKENIZER
# ═══════════════════════════════════════════════════════════════════════
def tokenize(text: str) -> list[str]:
"""PIST-compatible tokenizer: split on space, _, and : (added space for citation IDs)."""
normalized = text.replace("-", "_")
return [p for p in re.split(r"[ _:]", normalized) if p]
def node_context_text(g: nx.DiGraph, node: int) -> str:
"""Serialize a paper's citation neighborhood as space-separated text.
Format: paper_id cited_id1 cited_id2 ... cited_by_id1 cited_by_id2 ...
After space-splitting tokenization, adjacent paper IDs become bigram
pairs → mapped to 8×8 strand adjacency in PIST space.
"""
parts = [str(node)]
parts.extend(str(v) for v in g.successors(node))
parts.extend(str(v) for v in g.predecessors(node))
return " ".join(parts)
# ═══════════════════════════════════════════════════════════════════════
# §3 PIST 8×8 ADJACENCY MATRIX
# ═══════════════════════════════════════════════════════════════════════
def build_global_vocabulary(all_texts: list[str]) -> dict[str, int]:
all_tokens = set()
for text in all_texts:
for t in tokenize(text):
all_tokens.add(t)
return {t: i for i, t in enumerate(sorted(all_tokens))}
def strand_for_token(token: str, vocab: dict[str, int]) -> int:
return vocab[token] % N_STRANDS
def build_adjacency_matrix(tokens: list[str], vocab: dict[str, int]) -> list[list[int]]:
M = [[0] * N_STRANDS for _ in range(N_STRANDS)]
strands = [strand_for_token(t, vocab) for t in tokens]
for i in range(len(strands) - 1):
s1, s2 = strands[i], strands[i + 1]
M[s1][s2] += 1
return M
def matrix_hash(M: list[list[int]]) -> str:
return hashlib.sha256(
json.dumps(M, separators=(",", ":")).encode("utf-8")
).hexdigest()
# ═══════════════════════════════════════════════════════════════════════
# §4 SPECTRAL BIN ASSIGNMENT
# ═══════════════════════════════════════════════════════════════════════
def classify_pagerank_bin(score: float, max_score: float) -> str:
if score >= 0.1 * max_score: return "authority"
elif score >= 0.01 * max_score: return "secondary"
return "noise"
def classify_hits_bin(auth: float, max_auth: float, hub: float, max_hub: float) -> str:
auth_norm = auth / max_auth if max_auth else 0
hub_norm = hub / max_hub if max_hub else 0
if auth_norm > 0.1 and auth_norm > hub_norm: return "authority"
elif hub_norm > 0.1: return "hub"
elif auth_norm > 0.01 or hub_norm > 0.01: return "secondary"
return "noise"
def classify_fiedler_bin(val: float) -> str:
return "community_A" if val < 0 else "community_B"
def spectral_bin_summary(pr_bin: str, hits_bin: str) -> str:
return f"{pr_bin}|{hits_bin}"
# ═══════════════════════════════════════════════════════════════════════
# §5 FIEDLER ON DEGREE-WEIGHTED SUBSAMPLE + PAGERANK-KNN EXTRAPOLATION
# ═══════════════════════════════════════════════════════════════════════
def compute_fiedler_full(g: nx.DiGraph) -> tuple[dict[int, str], dict[int, float], dict]:
"""Compute Fiedler vector on a random 2-hop sampled subgraph,
then extend to all nodes via PageRank-kNN extrapolation.
Strategy:
1. Pick 500 random seeds, expand 2 hops for connectivity
2. Compute Fiedler via small eigsh (which='SM')
3. Extrapolate to all 34K nodes via kNN in PageRank space
(PageRank is an eigenvector centrality that preserves spectral
ordering; random sampling avoids degree bias in the computed set.)
"""
from scipy.sparse.linalg import eigsh
nodes = sorted(g.nodes())
ug = g.to_undirected()
n_total = len(nodes)
# 1. Random 2-hop sampling (representative, no degree bias)
seeds = random.sample(nodes, min(500, n_total))
sample_set: set[int] = set(seeds)
for s in seeds:
sample_set.update(g.successors(s))
sample_set.update(g.predecessors(s))
for s2 in list(g.successors(s)) + list(g.predecessors(s)):
sample_set.update(g.successors(s2))
sample_set.update(g.predecessors(s2))
sample = sorted(sample_set & set(nodes))
print(f" Fiedler: {len(seeds)} seeds, 2-hop → {len(sample)} nodes...", flush=True)
# Build undirected subgraph, take largest connected component
ug_s = g.subgraph(sample).to_undirected()
cc = max(nx.connected_components(ug_s), key=len)
ug_s = ug_s.subgraph(cc)
cc_nodes = sorted(cc)
print(f" Fiedler component: {len(cc_nodes)} nodes, "
f"{ug_s.number_of_edges()} edges", flush=True)
# 2. Compute Fiedler on the subgraph
L_s = nx.laplacian_matrix(ug_s, nodelist=cc_nodes).astype(float)
v0_s = np.array([ug_s.degree(n) for n in cc_nodes], dtype=float)
v0_s = v0_s / np.linalg.norm(v0_s)
vals, vecs = eigsh(L_s, k=2, which="SM", maxiter=10000, tol=1e-4, v0=v0_s)
order = np.argsort(vals)
fiedler_val = vals[order[1]]
fiedler_vec_real = vecs[:, order[1]]
sample_f = {cc_nodes[i]: float(fiedler_vec_real[i]) for i in range(len(cc_nodes))}
print(f" Fiedler eigenvalue: {fiedler_val:.6f} "
f"zero eig: {vals[order[0]]:.6e}", flush=True)
# 3. Extend to all nodes via PageRank-kNN
pr = nx.pagerank(g, alpha=0.85, max_iter=300)
pr_vals = {n: pr[n] for n in nodes}
sample_keys = set(sample_f.keys())
fiedler_bins: dict[int, str] = {}
fiedler_values: dict[int, float] = {}
k = 5
for n in nodes:
if n in sample_f:
fv = sample_f[n]
else:
nearest = sorted(sample_keys,
key=lambda sn: abs(pr_vals.get(n, 0.0) - pr_vals.get(sn, 0.0)))[:k]
signs = [1 if sample_f[sn] < 0 else 0 for sn in nearest]
majority = sum(signs) / len(signs) if nearest else 0.5
fv = -1.0 if majority > 0.5 else 1.0
fiedler_values[n] = fv
fiedler_bins[n] = classify_fiedler_bin(fv)
n_extrapolated = len(nodes) - len(sample_f)
print(f" Fiedler extension: {len(sample_f)} computed, "
f"{n_extrapolated} PageRank-kNN extrapolated", flush=True)
n_neg = sum(1 for v in sample_f.values() if v < 0)
n_pos = len(sample_f) - n_neg
collapse_report = {
"n_total": len(sample_f),
"n_neg": n_neg,
"n_pos": n_pos,
"collapse_ratio": max(n_neg, n_pos) / len(sample_f) if len(sample_f) else 0.0,
"var_sign": float(np.var([1 if v < 0 else 0 for v in sample_f.values()])),
"fiedler_eigenvalue": round(fiedler_val, 6),
"component_nodes": len(sample_f),
"total_nodes": len(nodes),
}
return fiedler_bins, fiedler_values, collapse_report
# ═══════════════════════════════════════════════════════════════════════
# §6 CROSS-TABULATION
# ═══════════════════════════════════════════════════════════════════════
def cross_tabulate(
pist_hash_to_nodes: dict[str, list[int]],
node_spectral: dict[int, dict],
) -> dict:
tab = {}
for phash, nodes in pist_hash_to_nodes.items():
combined_bins = Counter()
pr_bins = Counter()
hits_bins = Counter()
for n in nodes:
s = node_spectral.get(n, {})
combined_bins[s.get("spectral_summary", "unknown")] += 1
pr_bins[s.get("pr_bin", "unknown")] += 1
hits_bins[s.get("hits_bin", "unknown")] += 1
total = len(nodes)
def _entropy(cnt: Counter) -> float:
if total == 0:
return 0.0
return -sum((c / total) * math.log2(c / total) for c in cnt.values() if c > 0)
tab[phash] = {
"count": total,
"pagerank_mode": pr_bins.most_common(1)[0][0] if pr_bins else None,
"hits_mode": hits_bins.most_common(1)[0][0] if hits_bins else None,
"spectral_entropy": round(_entropy(combined_bins), 4),
}
return tab
# ═══════════════════════════════════════════════════════════════════════
# §7 MAIN
# ═══════════════════════════════════════════════════════════════════════
def main():
path = sys.argv[1] if len(sys.argv) > 1 else "/tmp/snap_data/cit-HepPh.txt.gz"
print(f"Loading {path}...", flush=True)
g = load_snap_edgelist(path)
print(f"Graph: {g.number_of_nodes()} nodes, {g.number_of_edges()} edges", flush=True)
nodes = sorted(g.nodes())
# ── 1. Serialize each paper as citation context text ───────────────
node_texts = {n: node_context_text(g, n) for n in nodes}
all_texts = list(node_texts.values())
# ── 2. Build PIST global vocabulary ────────────────────────────────
vocab = build_global_vocabulary(all_texts)
gvh = hashlib.sha256("|".join(vocab.keys()).encode("utf-8")).hexdigest()
print(f"Vocabulary: {len(vocab)} tokens hash={gvh}", flush=True)
# ── 3. Compute PIST 8×8 matrix per paper ───────────────────────────
node_pist = {}
for n in nodes:
tokens = tokenize(node_texts[n])
M = build_adjacency_matrix(tokens, vocab)
node_pist[n] = {"tokens": tokens, "matrix_8x8": M, "matrix_hash": matrix_hash(M)}
pist_hash_groups = defaultdict(list)
for n, info in node_pist.items():
pist_hash_groups[info["matrix_hash"]].append(n)
n_unique = len(pist_hash_groups)
print(f"Unique PIST matrix hashes: {n_unique} from {len(nodes)} papers", flush=True)
hash_sizes = [len(v) for v in pist_hash_groups.values()]
print(f" Hash group sizes: min={min(hash_sizes)} max={max(hash_sizes)} "
f"median={sorted(hash_sizes)[len(hash_sizes)//2]}", flush=True)
# Show a few sample matrices for verification
for n in nodes[:3]:
info = node_pist[n]
flat = [str(c) for row in info["matrix_8x8"] for c in row]
print(f" node={n} tokens={info['tokens'][:5]}... "
f"matrix_sum={sum(int(c) for c in flat)} "
f"nonzero={sum(1 for c in flat if int(c) > 0)}", flush=True)
# ── 4. Run spectral survey ─────────────────────────────────────────
print("PageRank...", flush=True)
pr = nx.pagerank(g, alpha=0.85, max_iter=300)
print("HITS...", flush=True)
try:
hubs, auths = nx.hits(g, max_iter=300, normalized=True)
except nx.PowerIterationFailedConvergence:
hubs = auths = {n: 1.0 / len(g) for n in g}
# Fiedler: diagnostic only (excluded from spectral scoring).
# Under k-hop projection, the Fiedler sign collapses to the empirical
# majority class (94.5% single-sign in our regime). See
# docs/fiedler_non_identifiability.md for the operator-theoretic
# statement of this non-identifiability condition.
print("Fiedler (diagnostic)...", flush=True)
fiedler_bins, fiedler_values, collapse_report = compute_fiedler_full(g)
collapse_ratio = (
max(collapse_report["n_neg"], collapse_report["n_pos"])
/ collapse_report["n_total"]
) if collapse_report["n_total"] else 0.0
print(f" Fiedler collapse ratio: {collapse_ratio:.4f} "
f"(Var(sign)) = {collapse_report['var_sign']:.4f})", flush=True)
max_pr = max(pr.values()) or 1.0
max_auth = max(auths.values()) or 1.0
max_hub = max(hubs.values()) or 1.0
node_spectral = {}
for n in nodes:
pr_b = classify_pagerank_bin(pr[n], max_pr)
hits_b = classify_hits_bin(auths.get(n, 0), max_auth, hubs.get(n, 0), max_hub)
node_spectral[n] = {
"pr": round(pr[n], 6), "pr_bin": pr_b,
"auth": round(auths.get(n, 0), 6),
"hub": round(hubs.get(n, 0), 6), "hits_bin": hits_b,
"fiedler": round(fiedler_values.get(n, 0.0), 6),
"fiedler_bin": fiedler_bins.get(n, "community_unknown"),
"spectral_summary": spectral_bin_summary(pr_b, hits_b),
}
spectral_bin_counts = Counter(v["spectral_summary"] for v in node_spectral.values())
print(f"Spectral combos (PR|HITS): {len(spectral_bin_counts)}", flush=True)
for label, count in spectral_bin_counts.most_common(10):
print(f" {label}: {count}", flush=True)
# ── 5. Cross-tabulate ──────────────────────────────────────────────
tab = cross_tabulate(pist_hash_groups, node_spectral)
pure_groups = sum(1 for v in tab.values() if v["spectral_entropy"] == 0.0)
mixed_groups = len(tab) - pure_groups
print(f"\nPIST hash groups pure spectral: {pure_groups}/{len(tab)} "
f"mixed: {mixed_groups}/{len(tab)}", flush=True)
# Mean entropy across all groups
mean_entropy = np.mean([v["spectral_entropy"] for v in tab.values()])
print(f"Mean spectral entropy across PIST groups: {mean_entropy:.4f}", flush=True)
# Diagnose mixing within multi-paper PIST groups.
pr_pure = 0
hits_pure = 0
for phash, nodes_p in pist_hash_groups.items():
if len(nodes_p) < 2:
continue
pr_bins = set(node_spectral[n]["pr_bin"] for n in nodes_p)
hits_bins = set(node_spectral[n]["hits_bin"] for n in nodes_p)
if len(pr_bins) == 1: pr_pure += 1
if len(hits_bins) == 1: hits_pure += 1
multi_paper_groups = sum(1 for v in pist_hash_groups.values() if len(v) > 1)
print(f"\nMulti-paper PIST groups: {multi_paper_groups}", flush=True)
print(f" PR-bin pure: {pr_pure}/{multi_paper_groups} "
f"({100*pr_pure/multi_paper_groups:.1f}%)", flush=True)
print(f" HITS-bin pure: {hits_pure}/{multi_paper_groups} "
f"({100*hits_pure/multi_paper_groups:.1f}%)", flush=True)
print(f" Fiedler: excluded from scoring "
f"(collapse_ratio={collapse_ratio:.4f}, "
f"Var(sign)={collapse_report['var_sign']:.4f})", flush=True)
# ── 6. Output ──────────────────────────────────────────────────────
predictions = []
for n in nodes:
info = node_pist.get(n, {})
spec = node_spectral.get(n, {})
predictions.append({
"node_id": n,
"matrix_hash": info.get("matrix_hash", ""),
"pagerank": spec.get("pr", 0), "pr_bin": spec.get("pr_bin", "unknown"),
"auth": spec.get("auth", 0), "hub": spec.get("hub", 0),
"hits_bin": spec.get("hits_bin", "unknown"),
"fiedler": spec.get("fiedler", 0), "fiedler_bin": spec.get("fiedler_bin", "unknown"),
"spectral_summary": spec.get("spectral_summary", "unknown"),
})
artifact = {
"schema": "snap_pist_spectral_crossval_v2",
"claim_boundary": "cross-validation-only;pr+hits-scoring;fiedler-diagnostic-only",
"source": os.path.basename(path),
"graph": {"nodes": g.number_of_nodes(), "edges": g.number_of_edges()},
"pist": {
"global_vocab_hash": gvh,
"unique_matrix_hashes": n_unique,
"matrix_schema": "token_strand_adjacency_8x8_v1",
},
"spectral": {
"distinct_combinations": len(spectral_bin_counts),
"fiedler_collapse": collapse_report,
},
"cross_tabulation": {
"pist_groups_with_pure_spectral_bins": pure_groups,
"pist_groups_with_mixed_spectral_bins": mixed_groups,
"total_pist_groups": len(tab),
"purity_ratio": round(pure_groups / len(tab), 4) if tab else 0.0,
"mean_spectral_entropy": round(mean_entropy, 4),
"scoring_dimensions": "pr_bin|hits_bin (fiedler excluded)",
},
}
out_path = f"/tmp/snap_pist_spectral_crossval_{os.path.basename(path)}.json"
with open(out_path, "w") as f:
json.dump(artifact, f, indent=2, sort_keys=True)
print(f"\nWrote {out_path}", flush=True)
# ── 7. Top PIST groups by size ─────────────────────────────────────
print("\nTop PIST groups by size:", flush=True)
for phash, pnodes in sorted(pist_hash_groups.items(), key=lambda x: -len(x[1]))[:25]:
info = tab[phash]
spec = node_spectral.get(pnodes[0], {})
print(f" PIST={phash[:16]} count={len(pnodes):>4} "
f"PR={spec.get('pr_bin','?'):>10} HITS={spec.get('hits_bin','?'):>10} "
f"H={info['spectral_entropy']:.3f}", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())