junwatu/nspsg-repro-scripts / repro_nspsg.py
junwatu's picture
download
raw
19.2 kB
#!/usr/bin/env python3
"""
Reproduction script for ICML 2026 paper #22179:
"Hard-Constrained Graph Generation with Discrete-Projection Diffusion" (NSPSG)
Claims:
1. NSPSG achieves 99%-100% validity rates for constrained graph generation
across heterogeneous constraints.
2. For complex non-linear constraints, NSPSG improves data validity by up to
43% while maintaining 99% validity.
"""
import argparse
import json
import os
import random
import time
import sys
from pathlib import Path
import networkx as nx
import numpy as np
# ---------------------------------------------------------------------------
# SMT-based projectors (Z3) for various graph constraints
# ---------------------------------------------------------------------------
def _z3_available():
try:
import z3
return True
except ImportError:
return False
def project_planar(graph: nx.Graph) -> nx.Graph:
"""Project onto planar graphs: greedily add edges while preserving
planarity to obtain a maximal planar subgraph."""
g = nx.Graph()
g.add_nodes_from(graph.nodes())
# Try adding edges from the original graph, keeping only those that
# preserve planarity
edges = list(graph.edges())
random.shuffle(edges)
for u, v in edges:
g.add_edge(u, v)
is_planar, _ = nx.check_planarity(g)
if not is_planar:
g.remove_edge(u, v)
is_planar, _ = nx.check_planarity(g)
return g, is_planar
def project_tree(graph: nx.Graph) -> nx.Graph:
"""Project onto tree / forest by removing edges until no cycles remain."""
g = graph.copy()
# Find a spanning forest (minimum edge removal)
if g.number_of_edges() == 0:
return g, True
# Use a BFS-based cycle removal
visited = set()
to_remove = []
for source in g.nodes():
if source in visited:
continue
# BFS to find spanning tree edges
parent = {source: None}
order = [source]
visited.add(source)
for node in order:
for neighbor in g.neighbors(node):
if neighbor not in visited:
visited.add(neighbor)
parent[neighbor] = node
order.append(neighbor)
# Remove non-tree edges
tree_edges = set()
for node, p in parent.items():
if p is not None:
tree_edges.add(tuple(sorted((node, p))))
for u, v in list(g.edges()):
if tuple(sorted((u, v))) not in tree_edges:
g.remove_edge(u, v)
is_forest = nx.is_forest(g)
return g, is_forest
def project_lobster(graph: nx.Graph) -> nx.Graph:
"""Project onto the set of lobster graphs (tree where removing leaves
yields a caterpillar, and removing those caterpillar leaves yields a path).
We use a heuristic: first project to tree, then iteratively trim growth."""
g, _ = project_tree(graph)
if g.number_of_nodes() <= 2:
return g, True
# Check lobster property
leaves1 = [n for n in g.nodes() if g.degree(n) <= 1]
g2 = g.copy()
g2.remove_nodes_from(leaves1)
if g2.number_of_nodes() <= 1:
return g, True
leaves2 = [n for n in g2.nodes() if g2.degree(n) <= 1]
g3 = g2.copy()
g3.remove_nodes_from(leaves2)
# g3 should be a path (max degree <= 2 and connected components are paths)
is_lobster = all(d <= 2 for _, d in g3.degree()) if g3.number_of_nodes() > 0 else True
if not is_lobster:
# Trim further
to_trim = [n for n in g3.nodes() if g3.degree(n) > 2]
for n in to_trim:
neighbors = list(g3.neighbors(n))
# Keep only 2 edges for nodes with degree > 2 in g3
keep_edges = list(g3.edges(n))[:2]
for nbr in neighbors:
edge = tuple(sorted((n, nbr)))
if edge not in keep_edges:
if g.has_edge(n, nbr):
g.remove_edge(n, nbr)
return g, True
def project_predecessor_balance(graph: nx.DiGraph, rho: float = 0.5) -> nx.DiGraph:
"""Project a directed graph to satisfy predecessor balance constraint.
For each node: |n0 - n1| / (2 * avg) <= rho where n0 = #0-type preds,
n1 = #1-type preds."""
g = graph.copy()
if _z3_available():
return _z3_project_predecessor_balance(g, rho)
return _heuristic_project_predecessor_balance(g, rho)
def _heuristic_project_predecessor_balance(g: nx.DiGraph, rho: float) -> nx.DiGraph:
"""Heuristic projection for predecessor balance."""
valid = True
for node in g.nodes():
preds = list(g.predecessors(node))
if not preds:
continue
n0 = sum(1 for p in preds if g.nodes[p].get("type", 0) == 0)
n1 = sum(1 for p in preds if g.nodes[p].get("type", 0) == 1)
total = len(preds)
if total == 0:
continue
imbalance = abs(n0 - n1) / max(2 * total, 1)
if imbalance > rho:
valid = False
# Remove edges to fix imbalance (remove oldest/most recent)
excess = int((imbalance - rho) * total / 2)
type1_preds = [(i, p) for i, p in enumerate(preds)
if g.nodes[p].get("type", 0) == 1]
type0_preds = [(i, p) for i, p in enumerate(preds)
if g.nodes[p].get("type", 0) == 0]
if n1 > n0:
to_remove = [p for _, p in type1_preds[:excess]]
else:
to_remove = [p for _, p in type0_preds[:excess]]
for p in to_remove:
g.remove_edge(p, node)
return g, valid
def _z3_project_predecessor_balance(g: nx.DiGraph, rho: float) -> nx.DiGraph:
"""Use Z3 SMT solver for predecessor balance projection."""
import z3
g = g.copy()
opt = z3.Optimize()
edges = {}
for u, v in list(g.edges()):
edges[(u, v)] = z3.Bool(f"e_{u}_{v}")
for node in g.nodes():
preds = list(g.predecessors(node))
if not preds:
continue
n0_vars = [edges[(p, node)] for p in preds
if g.nodes[p].get("type", 0) == 0 and (p, node) in edges]
n1_vars = [edges[(p, node)] for p in preds
if g.nodes[p].get("type", 0) == 1 and (p, node) in edges]
n0 = z3.Sum([z3.If(v, 1, 0) for v in n0_vars])
n1 = z3.Sum([z3.If(v, 1, 0) for v in n1_vars])
total = z3.Sum([z3.If(v, 1, 0) for v in n0_vars + n1_vars])
# constraint: |n0 - n1| <= rho * 2 * total
# encoded as: n0 - n1 <= rho * 2 * total AND n1 - n0 <= rho * 2 * total
rhs = z3.If(total > 0, rho * 2 * total, 1)
opt.add(z3.And(z3.And(n0 - n1 <= rhs), z3.And(n1 - n0 <= rhs)))
# Maximize number of retained edges
edge_sum = z3.Sum([z3.If(v, 1, 0) for v in edges.values()])
opt.maximize(edge_sum)
if opt.check() == z3.sat:
model = opt.model()
to_remove = [(u, v) for (u, v), var in edges.items()
if not model[var]]
for u, v in to_remove:
g.remove_edge(u, v)
return g, True
# ---------------------------------------------------------------------------
# Discrete diffusion graph generator (simplified)
# ---------------------------------------------------------------------------
class DiscreteGraphDiffusion:
"""Minimal discrete diffusion for graph generation with projection."""
def __init__(self, num_nodes: int = 32, T: int = 100, device: str = "cpu"):
self.num_nodes = num_nodes
self.T = T
self.device = device
def generate(self, num_graphs: int = 100, projection_fn=None,
project_every: int = 5) -> list:
"""Generate graphs using reverse diffusion with optional projection."""
graphs = []
for _ in range(num_graphs):
# Start from random graph (noisy state)
g = nx.erdos_renyi_graph(self.num_nodes, 0.3, seed=random.randint(0, 10000))
# Simulate reverse diffusion with projection
for step in range(self.T, 0, -1):
# Denoise step: randomly add/remove edges toward target density
density = 0.1 + 0.4 * (step / self.T)
self._denoise_step(g, density)
# Apply projection if specified at this step
if projection_fn and step % project_every == 0:
g, _ = projection_fn(g)
# Final projection
if projection_fn:
g, valid = projection_fn(g)
else:
valid = True
graphs.append((g, valid))
return graphs
def _denoise_step(self, g: nx.Graph, target_density: float):
"""Single denoising step."""
current_edges = g.number_of_edges()
max_edges = self.num_nodes * (self.num_nodes - 1) // 2
target_edges = int(target_density * max_edges)
# Add edges
while g.number_of_edges() < target_edges:
u = random.randint(0, self.num_nodes - 1)
v = random.randint(0, self.num_nodes - 1)
if u != v and not g.has_edge(u, v):
g.add_edge(u, v)
# Remove edges
edges_to_remove = list(g.edges())
random.shuffle(edges_to_remove)
while g.number_of_edges() > target_edges and edges_to_remove:
g.remove_edge(*edges_to_remove.pop())
# ---------------------------------------------------------------------------
# Claim 1: Structural constraint evaluation
# ---------------------------------------------------------------------------
def evaluate_claim1(output_dir: Path, num_graphs: int = 100,
num_nodes: int = 20, use_z3: bool = False):
"""Evaluate Claim 1: NSPSG achieves 99-100% validity for structural constraints."""
results = {}
generator = DiscreteGraphDiffusion(num_nodes=num_nodes, T=50, device="cpu")
constraints = {
"planar": project_planar,
"tree": project_tree,
"lobster": project_lobster,
}
for constraint_name, proj_fn in constraints.items():
print(f"\n=== Evaluating {constraint_name} constraint ===")
# Without projection
t0 = time.time()
graphs_no_proj = generator.generate(
num_graphs=num_graphs, projection_fn=None
)
t_no_proj = time.time() - t0
valid_no_proj = sum(1 for _, v in graphs_no_proj if v)
valid_pct_no_proj = valid_no_proj / num_graphs * 100
# With projection (NSPSG)
t0 = time.time()
graphs_proj = generator.generate(
num_graphs=num_graphs, projection_fn=proj_fn, project_every=10
)
t_proj = time.time() - t0
valid_proj = sum(1 for _, v in graphs_proj if v)
# Re-check validity for projected graphs
rechecked = 0
for g, _ in graphs_proj:
if constraint_name == "planar":
is_valid = nx.check_planarity(g)[0]
elif constraint_name == "tree":
is_valid = nx.is_forest(g)
elif constraint_name == "lobster":
is_valid = _check_lobster(g)
else:
is_valid = True
if is_valid:
rechecked += 1
valid_pct_proj = rechecked / num_graphs * 100
print(f" Without projection: {valid_pct_no_proj:.1f}% valid "
f"({t_no_proj:.2f}s)")
print(f" With NSPSG projection: {valid_pct_proj:.1f}% valid "
f"({t_proj:.2f}s)")
results[constraint_name] = {
"no_projection_valid_pct": round(valid_pct_no_proj, 1),
"nspsg_valid_pct": round(valid_pct_proj, 1),
"no_projection_time_s": round(t_no_proj, 2),
"nspsg_time_s": round(t_proj, 2),
"num_graphs": num_graphs,
"num_nodes": num_nodes,
}
# Save results
results_path = output_dir / "claim1_results.json"
with open(results_path, "w") as f:
json.dump(results, f, indent=2)
print(f"\nClaim 1 results saved to {results_path}")
# Summary
print("\n=== Claim 1 Summary ===")
all_above_99 = all(r["nspsg_valid_pct"] >= 99.0 for r in results.values())
print(f"NSPSG achieves >=99% validity across all constraints: {all_above_99}")
for name, r in results.items():
print(f" {name}: {r['nspsg_valid_pct']}% valid "
f"(baseline: {r['no_projection_valid_pct']}%)")
return results
def _check_lobster(g: nx.Graph) -> bool:
"""Check if a graph is a lobster graph."""
if not nx.is_forest(g):
return False
if g.number_of_nodes() <= 2:
return True
leaves1 = [n for n in g.nodes() if g.degree(n) <= 1]
g2 = g.copy()
g2.remove_nodes_from(leaves1)
if g2.number_of_nodes() <= 1:
return True
leaves2 = [n for n in g2.nodes() if g2.degree(n) <= 1]
g3 = g2.copy()
g3.remove_nodes_from(leaves2)
return all(d <= 2 for _, d in g3.degree()) if g3.number_of_nodes() > 0 else True
# ---------------------------------------------------------------------------
# Claim 2: Non-linear constraint evaluation
# ---------------------------------------------------------------------------
def evaluate_claim2(output_dir: Path, num_graphs: int = 100,
num_nodes: int = 15, use_z3: bool = False):
"""Evaluate Claim 2: NSPSG improves validity by up to 43% on non-linear
constraints (predecessor balance)."""
results = {}
rhos = [0.0, 0.5, 1.0]
for rho in rhos:
print(f"\n=== Evaluating predecessor balance constraint (rho={rho}) ===")
# Generate random DAGs
valid_before = 0
valid_after = 0
for _ in range(num_graphs):
# Create DAG with random topological order
g = nx.DiGraph()
g.add_nodes_from(range(num_nodes))
# Random node types
for n in g.nodes():
g.nodes[n]["type"] = random.randint(0, 1)
# Add edges respecting topological order
order = list(range(num_nodes))
random.shuffle(order)
for i in range(num_nodes):
for j in range(i + 1, num_nodes):
if random.random() < 0.3:
g.add_edge(order[j], order[i]) # j -> i (j after i in order)
# Check original validity
_, valid_orig = _heuristic_project_predecessor_balance(
g.copy(), rho
)
if valid_orig:
valid_before += 1
# Apply SMT projection
g_proj, _ = project_predecessor_balance(g, rho)
# Check projected validity (re-check)
_, valid_proj = _heuristic_project_predecessor_balance(
g_proj, rho
)
if valid_proj:
valid_after += 1
valid_before_pct = valid_before / num_graphs * 100
valid_after_pct = valid_after / num_graphs * 100
improvement = valid_after_pct - valid_before_pct
print(f" Before projection: {valid_before_pct:.1f}% valid")
print(f" After NSPSG projection: {valid_after_pct:.1f}% valid")
print(f" Improvement: {improvement:.1f} pp")
results[f"rho_{rho}"] = {
"rho": rho,
"before_valid_pct": round(valid_before_pct, 1),
"nspsg_valid_pct": round(valid_after_pct, 1),
"improvement_pp": round(improvement, 1),
"num_graphs": num_graphs,
}
results_path = output_dir / "claim2_results.json"
with open(results_path, "w") as f:
json.dump(results, f, indent=2)
print(f"\nClaim 2 results saved to {results_path}")
print("\n=== Claim 2 Summary ===")
max_improvement = max(r["improvement_pp"] for r in results.values())
print(f"Max improvement: {max_improvement} percentage points")
for name, r in results.items():
print(f" {name}: {r['nspsg_valid_pct']}% valid "
f"(improvement: {r['improvement_pp']} pp)")
return results
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Reproduce ICML 2026 NSPSG paper claims"
)
parser.add_argument("--output-dir", default="./repro_output",
help="Output directory for results")
parser.add_argument("--num-graphs", type=int, default=100,
help="Number of graphs to generate per experiment")
parser.add_argument("--num-nodes", type=int, default=20,
help="Number of nodes per graph")
parser.add_argument("--claim", type=int, choices=[1, 2, 0], default=0,
help="Which claim to evaluate (0=both)")
parser.add_argument("--use-z3", action="store_true",
help="Use Z3 SMT solver instead of heuristics")
args = parser.parse_args()
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Save environment info
env_info = {
"python_version": sys.version,
"networkx_version": nx.__version__,
"z3_available": _z3_available(),
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()),
"args": vars(args),
}
with open(output_dir / "env_info.json", "w") as f:
json.dump(env_info, f, indent=2)
all_results = {}
if args.claim in (0, 1):
print("=" * 60)
print("EVALUATING CLAIM 1: Structural Constraints")
print("=" * 60)
c1 = evaluate_claim1(output_dir, num_graphs=args.num_graphs,
num_nodes=args.num_nodes, use_z3=args.use_z3)
all_results["claim1"] = c1
if args.claim in (0, 2):
print("\n" + "=" * 60)
print("EVALUATING CLAIM 2: Non-Linear Arithmetic Constraints")
print("=" * 60)
c2 = evaluate_claim2(output_dir, num_graphs=args.num_graphs,
num_nodes=args.num_nodes, use_z3=args.use_z3)
all_results["claim2"] = c2
# Final report
print("\n" + "=" * 60)
print("FINAL REPRODUCTION REPORT")
print("=" * 60)
if "claim1" in all_results:
c1 = all_results["claim1"]
all_valid = all(r["nspsg_valid_pct"] >= 99.0 for r in c1.values())
print(f"\nClaim 1: {all_valid}")
print(f" Reported: NSPSG achieves 99%-100% validity")
for name, r in c1.items():
print(f" {name}: {r['nspsg_valid_pct']}% (baseline: {r['no_projection_valid_pct']}%)")
if "claim2" in all_results:
c2 = all_results["claim2"]
max_imp = max(r["improvement_pp"] for r in c2.values())
print(f"\nClaim 2: Improvement up to {max_imp} pp")
print(f" Reported: up to 43% improvement while maintaining 99% validity")
for name, r in c2.items():
print(f" {name}: {r['nspsg_valid_pct']}% valid (improvement: {r['improvement_pp']} pp)")
print("\nResults written to:", output_dir.resolve())
if __name__ == "__main__":
main()

Xet Storage Details

Size:
19.2 kB
·
Xet hash:
4207a23a03e886f75951842c4277bfc47296db072a4d2845d43586ec32f24e60

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.