nPC7M7XLEv / current /code /claim5.py
DineshAI's picture
Add current claim verification evidence (part 1)
c6eaad2 verified
Raw
History Blame Contribute Delete
23.7 kB
"""Full TUDataset Table-4 reproduction for Claim 5."""
from __future__ import annotations
import hashlib
import json
import zipfile
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import ot
from scipy.sparse.csgraph import shortest_path
from sklearn.metrics import accuracy_score
from sklearn.model_selection import StratifiedKFold
from sklearn.svm import SVC
from .claim4 import download
DATA = {
"MUTAG": {
"url": "https://www.chrsmrrs.com/graphkerneldatasets/MUTAG.zip",
"sha256": "c419bdc853c367d2d83da4973c45100954ae15e10f5ae2cddde6ca431f8207f6",
"graphs": 188,
"nodes": 3371,
},
"ENZYMES": {
"url": "https://www.chrsmrrs.com/graphkerneldatasets/ENZYMES.zip",
"sha256": "13d832eb6ffa084192daf6e5750250028a18437ee692c38d29a10cd60e18aaf4",
"graphs": 600,
"nodes": 19580,
},
}
ALPHAS = (0.0, 0.25, 0.5, 0.75, 1.0)
OUTER_SEEDS = (260727, 260728, 260729)
C_GRID = (0.1, 1.0, 10.0, 100.0)
GAMMA_GRID = (0.001, 0.01, 0.1, 1.0, 10.0)
MAX_ITER = 200
WORKERS = 8
@dataclass(frozen=True)
class Graph:
distance: np.ndarray
features: np.ndarray
label: int
def stable(value: float) -> float:
return round(float(value), 10)
def read_numbers(path: Path, dtype: type = float, delimiter: str | None = None) -> np.ndarray:
return np.loadtxt(path, dtype=dtype, delimiter=delimiter)
def load_tu(root: Path, name: str) -> tuple[list[Graph], dict[str, object]]:
indicator = read_numbers(root / f"{name}_graph_indicator.txt", int)
graph_labels = read_numbers(root / f"{name}_graph_labels.txt", int)
edges = read_numbers(root / f"{name}_A.txt", int, ",") - 1
attribute_path = root / f"{name}_node_attributes.txt"
label_path = root / f"{name}_node_labels.txt"
if attribute_path.exists():
raw = np.atleast_2d(read_numbers(attribute_path, float, ","))
if raw.shape[0] != len(indicator):
raw = raw.T
scale = raw.std(axis=0)
scale[scale < 1e-12] = 1.0
node_features = (raw - raw.mean(axis=0)) / scale
feature_kind = "provided attributes, dataset-wise z-scored"
elif label_path.exists():
labels = read_numbers(label_path, int)
categories = sorted(set(int(value) for value in labels))
lookup = {value: index for index, value in enumerate(categories)}
node_features = np.eye(len(categories))[
[lookup[int(value)] for value in labels]
]
feature_kind = "provided categorical labels, one-hot encoded"
else:
raise ValueError(f"{name}: no supplied node information")
graphs: list[Graph] = []
disconnected = 0
for graph_id in range(1, len(graph_labels) + 1):
global_nodes = np.where(indicator == graph_id)[0]
local = {int(node): index for index, node in enumerate(global_nodes)}
adjacency = np.zeros((len(global_nodes), len(global_nodes)))
for source, target in edges:
if int(source) in local and int(target) in local:
i, j = local[int(source)], local[int(target)]
adjacency[i, j] = adjacency[j, i] = 1.0
distance = shortest_path(adjacency, directed=False, unweighted=True)
finite = np.isfinite(distance)
if not np.all(finite):
disconnected += 1
fill = float(np.max(distance[finite])) if np.any(finite) else 1.0
distance[~finite] = fill
maximum = float(distance.max())
if maximum:
distance /= maximum
graphs.append(
Graph(
np.asarray(distance, dtype=np.float64),
np.asarray(node_features[global_nodes], dtype=np.float64),
int(graph_labels[graph_id - 1]),
)
)
counts = [len(graph.distance) for graph in graphs]
return graphs, {
"graphs": len(graphs),
"nodes": int(len(indicator)),
"node_count_min": min(counts),
"node_count_max": max(counts),
"node_count_mean": stable(np.mean(counts)),
"classes": sorted(set(int(value) for value in graph_labels)),
"feature_kind": feature_kind,
"disconnected_graphs_filled_with_finite_diameter": disconnected,
}
def feature_cost(x: np.ndarray, y: np.ndarray) -> np.ndarray:
cost = np.sum((x[:, None, :] - y[None, :, :]) ** 2, axis=2)
maximum = float(cost.max())
return cost / maximum if maximum else cost
def cdot_objective(
dx: np.ndarray,
dy: np.ndarray,
cost: np.ndarray,
coupling: np.ndarray,
alpha: float,
) -> float:
n, m = coupling.shape
residual = (dx / n) @ coupling - coupling @ (dy / m)
return float(
(1.0 - alpha) * np.sum(cost * coupling)
+ 0.5 * alpha * n * m * np.sum(residual * residual)
)
def cdot_distance(
dx: np.ndarray, dy: np.ndarray, cost: np.ndarray, alpha: float
) -> tuple[float, dict[str, object]]:
n, m = cost.shape
a, b = np.full(n, 1 / n), np.full(m, 1 / m)
coupling = np.outer(a, b)
dxn, dyn = dx / n, dy / m
residual = dxn @ coupling - coupling @ dyn
start = previous = cdot_objective(dx, dy, cost, coupling, alpha)
nonmonotone = stationary = 0
iterations = 0
for iteration in range(MAX_ITER):
gradient = (1.0 - alpha) * cost + alpha * n * m * (
dxn.T @ residual - residual @ dyn.T
)
atom = ot.emd(a, b, gradient, numItermax=100000)
direction = atom - coupling
residual_direction = dxn @ direction - direction @ dyn
linear = float(
(1.0 - alpha) * np.sum(cost * direction)
+ alpha * n * m * np.sum(residual * residual_direction)
)
quadratic = float(
0.5 * alpha * n * m * np.sum(residual_direction**2)
)
if quadratic > 1e-20:
step = float(np.clip(-linear / (2 * quadratic), 0.0, 1.0))
else:
step = float(linear < 0)
coupling += step * direction
residual += step * residual_direction
current = cdot_objective(dx, dy, cost, coupling, alpha)
nonmonotone += int(current > previous + 1e-9)
improvement = previous - current
stationary = (
stationary + 1
if improvement <= 1e-11 * max(1.0, abs(previous))
else 0
)
previous = current
iterations = iteration + 1
if iteration >= 9 and stationary >= 5:
break
marginal = max(
float(np.max(np.abs(coupling.sum(axis=1) - a))),
float(np.max(np.abs(coupling.sum(axis=0) - b))),
)
return float(np.sqrt(max(previous, 0.0))), {
"iterations": iterations,
"start": start,
"final": previous,
"nonmonotone": nonmonotone,
"marginal_error": marginal,
}
def fgw_distance(
dx: np.ndarray, dy: np.ndarray, cost: np.ndarray, alpha: float
) -> tuple[float, dict[str, object]]:
n, m = cost.shape
a, b = np.full(n, 1 / n), np.full(m, 1 / m)
value, log = ot.gromov.fused_gromov_wasserstein2(
cost,
dx,
dy,
a,
b,
loss_fun="square_loss",
alpha=alpha,
armijo=False,
max_iter=MAX_ITER,
tol_rel=1e-9,
tol_abs=1e-9,
log=True,
)
losses = [float(item) for item in log.get("loss", [])]
return float(np.sqrt(max(float(value), 0.0))), {
"iterations": max(0, len(losses) - 1),
"start": losses[0] if losses else None,
"final": float(value),
"nonmonotone": sum(
later > earlier + 1e-8
for earlier, later in zip(losses, losses[1:])
),
"marginal_error": None,
}
_GRAPHS: list[Graph] = []
def initialize_worker(graphs: list[Graph]) -> None:
global _GRAPHS
_GRAPHS = graphs
def pair_task(pair: tuple[int, int]) -> dict[str, object]:
i, j = pair
x, y = _GRAPHS[i], _GRAPHS[j]
cost = feature_cost(x.features, y.features)
row: dict[str, object] = {"left": i, "right": j}
for alpha in ALPHAS:
slug = f"a{int(round(alpha * 100)):03d}"
cdot_value, cdot_diag = cdot_distance(
x.distance, y.distance, cost, alpha
)
if alpha == 0.0:
fgw_value, fgw_diag = cdot_value, dict(cdot_diag)
else:
fgw_value, fgw_diag = fgw_distance(
x.distance, y.distance, cost, alpha
)
row[f"CDOT_{slug}"] = stable(cdot_value)
row[f"FGW_{slug}"] = stable(fgw_value)
for method, diag in (("cdot", cdot_diag), ("fgw", fgw_diag)):
row[f"{method}_{slug}_iterations"] = diag["iterations"]
row[f"{method}_{slug}_nonmonotone"] = diag["nonmonotone"]
row[f"{method}_{slug}_marginal_error"] = diag["marginal_error"]
return row
def chunk_task(pairs: list[tuple[int, int]]) -> list[dict[str, object]]:
return [pair_task(pair) for pair in pairs]
def matrix_hash(matrix: np.ndarray) -> str:
return hashlib.sha256(
np.asarray(matrix, dtype="<f8", order="C").tobytes()
).hexdigest()
def pairwise_distances(
graphs: list[Graph],
) -> tuple[dict[str, np.ndarray], dict[str, object], list[dict[str, object]]]:
pairs = [
(i, j) for i in range(len(graphs)) for j in range(i + 1, len(graphs))
]
matrices = {
f"{method}_a{int(round(alpha * 100)):03d}": np.zeros(
(len(graphs), len(graphs)), dtype=np.float64
)
for method in ("CDOT", "FGW")
for alpha in ALPHAS
}
selected_indices = set(
np.linspace(0, len(pairs) - 1, num=20, dtype=int).tolist()
)
selected_rows: list[dict[str, object]] = []
maximum_marginal = 0.0
cdot_nonmonotone = fgw_nonmonotone = 0
iteration_totals = {"CDOT": 0, "FGW": 0}
chunks = [pairs[start : start + 64] for start in range(0, len(pairs), 64)]
completed = 0
with ProcessPoolExecutor(
max_workers=WORKERS,
initializer=initialize_worker,
initargs=(graphs,),
) as executor:
for chunk_rows in executor.map(chunk_task, chunks):
for row in chunk_rows:
pair_index = completed
i, j = int(row["left"]), int(row["right"])
for alpha in ALPHAS:
slug = f"a{int(round(alpha * 100)):03d}"
for method in ("CDOT", "FGW"):
value = float(row[f"{method}_{slug}"])
matrices[f"{method}_{slug}"][i, j] = value
matrices[f"{method}_{slug}"][j, i] = value
iteration_totals[method] += int(
row[f"{method.lower()}_{slug}_iterations"]
)
maximum_marginal = max(
maximum_marginal,
float(row[f"cdot_{slug}_marginal_error"]),
)
cdot_nonmonotone += int(
row[f"cdot_{slug}_nonmonotone"]
)
fgw_nonmonotone += int(
row[f"fgw_{slug}_nonmonotone"]
)
if pair_index in selected_indices:
selected_rows.append(row)
completed += 1
if completed % 1024 < len(chunk_rows) or completed == len(pairs):
print(
json.dumps(
{"completed_pairs": completed, "pairs": len(pairs)}
),
flush=True,
)
finite_symmetric = all(
np.isfinite(matrix).all()
and np.max(np.abs(matrix - matrix.T)) == 0
and np.max(np.abs(np.diag(matrix))) == 0
for matrix in matrices.values()
)
diagnostics = {
"pairs": len(pairs),
"pair_alpha_method_cells": len(pairs) * len(ALPHAS) * 2,
"maximum_cdot_marginal_error": maximum_marginal,
"cdot_nonmonotone_steps": cdot_nonmonotone,
"fgw_nonmonotone_steps": fgw_nonmonotone,
"mean_iterations": {
method: iteration_totals[method] / (len(pairs) * len(ALPHAS))
for method in iteration_totals
},
"distance_matrices_finite_symmetric_zero_diagonal": finite_symmetric,
"matrix_sha256": {
name: matrix_hash(matrix) for name, matrix in matrices.items()
},
}
return matrices, diagnostics, selected_rows
def tune_and_score(
distances: dict[float, np.ndarray],
labels: np.ndarray,
seed: int,
permuted: bool,
) -> list[dict[str, object]]:
used = (
np.random.default_rng(seed + 900000).permutation(labels)
if permuted
else labels
)
outer = StratifiedKFold(n_splits=10, shuffle=True, random_state=seed)
rows: list[dict[str, object]] = []
for fold, (train, test) in enumerate(
outer.split(distances[ALPHAS[0]], used), 1
):
inner = StratifiedKFold(
n_splits=5, shuffle=True, random_state=seed * 100 + fold
)
best: tuple[float, float, float, float] | None = None
for alpha in ALPHAS:
for gamma in GAMMA_GRID:
kernel = np.exp(-gamma * distances[alpha] ** 2)
for regularization in C_GRID:
scores: list[float] = []
for inner_train_local, validation_local in inner.split(
train, used[train]
):
inner_train = train[inner_train_local]
validation = train[validation_local]
model = SVC(C=regularization, kernel="precomputed")
model.fit(
kernel[np.ix_(inner_train, inner_train)],
used[inner_train],
)
prediction = model.predict(
kernel[np.ix_(validation, inner_train)]
)
scores.append(
accuracy_score(used[validation], prediction)
)
candidate = (
float(np.mean(scores)),
-alpha,
-gamma,
-regularization,
)
if best is None or candidate > best:
best = candidate
if best is None:
raise RuntimeError("empty nested-CV grid")
_, neg_alpha, neg_gamma, neg_c = best
alpha, gamma, regularization = -neg_alpha, -neg_gamma, -neg_c
kernel = np.exp(-gamma * distances[alpha] ** 2)
model = SVC(C=regularization, kernel="precomputed")
model.fit(kernel[np.ix_(train, train)], used[train])
prediction = model.predict(kernel[np.ix_(test, train)])
rows.append(
{
"outer_seed": seed,
"fold": fold,
"alpha": alpha,
"gamma": gamma,
"C": regularization,
"accuracy": stable(accuracy_score(used[test], prediction)),
"permuted_outcome_control": permuted,
}
)
return rows
def summarize(rows: list[dict[str, object]]) -> dict[str, float | int]:
values = np.asarray([float(row["accuracy"]) for row in rows])
return {
"outer_folds": len(values),
"mean_accuracy": stable(values.mean()),
"sample_std_accuracy": stable(values.std(ddof=1)),
"standard_error": stable(values.std(ddof=1) / np.sqrt(len(values))),
}
def run(output: Path) -> dict[str, object]:
output.mkdir(parents=True, exist_ok=True)
work = output / "work"
work.mkdir(exist_ok=True)
all_results: dict[str, object] = {}
cv_raw: list[dict[str, object]] = []
control_raw: list[dict[str, object]] = []
selected_raw: list[dict[str, object]] = []
integrity_gates: dict[str, bool] = {}
for name, expected in DATA.items():
archive = work / f"{name}.zip"
provenance = download(
str(expected["url"]), archive, str(expected["sha256"])
)
extraction = work / name
extraction.mkdir(exist_ok=True)
with zipfile.ZipFile(archive) as handle:
handle.extractall(extraction)
indicator = next(extraction.rglob(f"{name}_graph_indicator.txt"))
graphs, metadata = load_tu(indicator.parent, name)
matrices, diagnostics, selected = pairwise_distances(graphs)
labels = np.asarray([graph.label for graph in graphs])
summaries: dict[str, object] = {}
seedwise: dict[str, dict[str, float]] = {
str(seed): {} for seed in OUTER_SEEDS
}
for method in ("CDOT", "FGW"):
method_distances = {
alpha: matrices[
f"{method}_a{int(round(alpha * 100)):03d}"
]
for alpha in ALPHAS
}
method_rows: list[dict[str, object]] = []
method_controls: list[dict[str, object]] = []
for seed in OUTER_SEEDS:
rows = tune_and_score(
method_distances, labels, seed, permuted=False
)
controls = tune_and_score(
method_distances, labels, seed, permuted=True
)
for row in rows:
row.update({"dataset": name, "method": method})
for row in controls:
row.update({"dataset": name, "method": method})
method_rows.extend(rows)
method_controls.extend(controls)
seedwise[str(seed)][method] = stable(
np.mean([float(row["accuracy"]) for row in rows])
)
summaries[method] = {
**summarize(method_rows),
"permuted_outcome_mean_accuracy": stable(
np.mean(
[float(row["accuracy"]) for row in method_controls]
)
),
}
cv_raw.extend(method_rows)
control_raw.extend(method_controls)
for seed in OUTER_SEEDS:
values = seedwise[str(seed)]
values["CDOT_minus_FGW"] = stable(
values["CDOT"] - values["FGW"]
)
selected_raw.extend(
[{"dataset": name, **row} for row in selected]
)
majority = max(np.bincount(np.unique(labels, return_inverse=True)[1])) / len(labels)
dataset_gates = {
"full_graph_count": len(graphs) == int(expected["graphs"]),
"full_node_count": int(metadata["nodes"]) == int(expected["nodes"]),
"all_pairs_executed": diagnostics["pairs"]
== len(graphs) * (len(graphs) - 1) // 2,
"matrices_finite_symmetric": bool(
diagnostics[
"distance_matrices_finite_symmetric_zero_diagonal"
]
),
"cdot_marginals_preserved": float(
diagnostics["maximum_cdot_marginal_error"]
)
< 1e-8,
"cdot_traces_monotone": diagnostics["cdot_nonmonotone_steps"]
== 0,
"fgw_traces_monotone": diagnostics["fgw_nonmonotone_steps"]
== 0,
"three_repeated_nested_10fold_runs": all(
int(summaries[method]["outer_folds"]) == 30
for method in ("CDOT", "FGW")
),
"permuted_control_near_chance_and_degraded": all(
float(summaries[method]["permuted_outcome_mean_accuracy"])
<= majority + 0.05
and float(summaries[method]["permuted_outcome_mean_accuracy"])
< float(summaries[method]["mean_accuracy"]) - 0.05
for method in ("CDOT", "FGW")
),
}
for gate, passed in dataset_gates.items():
integrity_gates[f"{name}_{gate}"] = bool(passed)
all_results[name] = {
"data": {**provenance, **metadata},
"diagnostics": diagnostics,
"summaries": summaries,
"seedwise_mean_accuracy": seedwise,
"CDOT_minus_FGW_accuracy": stable(
float(summaries["CDOT"]["mean_accuracy"])
- float(summaries["FGW"]["mean_accuracy"])
),
"majority_class_rate": stable(majority),
"gates": dataset_gates,
}
del matrices
enzymes_seedwise = all_results["ENZYMES"]["seedwise_mean_accuracy"]
reversed_every_seed = all(
float(enzymes_seedwise[str(seed)]["CDOT_minus_FGW"]) < 0
for seed in OUTER_SEEDS
)
supports_paper_direction = all(
float(all_results[name]["CDOT_minus_FGW_accuracy"]) > 0
for name in ("MUTAG", "ENZYMES")
)
status = (
"FALSIFIED"
if all(integrity_gates.values()) and reversed_every_seed
else "VERIFIED"
if all(integrity_gates.values()) and supports_paper_direction
else "BLOCKED"
)
result = {
"claim": 5,
"status": status,
"paper_values": {
"MUTAG_CDOT": [0.8617, 0.07],
"MUTAG_FGW": [0.8249, 0.08],
"ENZYMES_CDOT": [0.5133, 0.04],
"ENZYMES_FGW": [0.4450, 0.03],
},
"protocol": {
"all_graphs_and_unordered_pairs": True,
"geometry": "unweighted geodesic divided by graph diameter",
"node_information": "provided labels or attributes",
"alpha_grid": list(ALPHAS),
"kernel": "exp(-gamma*D^2)",
"outer_cv": "three seeded stratified 10-fold runs",
"inner_cv": "stratified 5-fold joint alpha/C/gamma search",
"C_grid": list(C_GRID),
"gamma_grid": list(GAMMA_GRID),
"outer_seeds": list(OUTER_SEEDS),
"max_optimizer_iterations": MAX_ITER,
"process_workers": WORKERS,
},
"disclosed_reconstruction_ambiguities": [
"source split seeds are unpublished",
"ENZYMES attribute standardization is unspecified; dataset-wise z-scoring is used",
"feature-cost scaling is unspecified; per-pair max normalization is used",
"optimizer stopping tolerance is unspecified; disclosed at-most-200 stopping is used",
],
"results": all_results,
"classification": {
"CDOT_greater_than_FGW_on_both": supports_paper_direction,
"ENZYMES_direction_reversed_under_every_seed": reversed_every_seed,
"reason": (
"direct protocol-matched direction reversal on ENZYMES"
if reversed_every_seed
else "paper direction observed on both datasets"
if supports_paper_direction
else "neither a stable verification nor falsification"
),
},
"gates": integrity_gates,
"all_gates_pass": all(integrity_gates.values()),
}
for name, value in (
("claim_5_nested_cv_rows.json", cv_raw),
("claim_5_permuted_controls.json", control_raw),
("claim_5_selected_pair_diagnostics.json", selected_raw),
("claim_5_result.json", result),
):
(output / name).write_text(
json.dumps(value, indent=2) + "\n", encoding="utf-8"
)
if not result["all_gates_pass"] or status == "BLOCKED":
raise RuntimeError("Claim 5 evidence gates or verdict resolution failed")
return result