SabaPivot's picture
Add full-public-MATH hard-soft agreement audit and bundle v3
8d3cfa6 verified
Raw History Blame Contribute Delete
8.77 kB
#!/usr/bin/env python3
"""Benchmark-scale hard/soft agreement audit on all public MATH solutions.
The paper-specific Atomic Dependency Graph annotations and learned factuality
scorer are not public. This audit therefore makes a deliberately scoped proxy:
each gold solution is split into reasoning steps, the steps form a chain DAG,
and a deterministic structural risk score is assigned to each step. The DCF
prediction relaxation itself follows the equations audited for Theorem 3.2.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import json
import math
import re
from pathlib import Path
import numpy as np
import pyarrow.parquet as pq
DATASET = "https://huggingface.co/datasets/qwedsacf/competition_math"
DATASET_REVISION = "e839825f9ec5c6cfa585c654a59610969ec13993"
ALPHAS = tuple(round(i / 100, 2) for i in range(1, 11))
THRESHOLDS = tuple(round(i / 20, 2) for i in range(1, 20))
TEMPERATURE = 0.01
ANCESTOR_WEIGHT = 1.0
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def split_steps(solution: str) -> list[str]:
text = re.sub(r"\s+", " ", solution).strip()
parts = [part.strip() for part in re.split(r"(?<=[.!?])\s+|(?=\\boxed)|(?=\\Rightarrow)", text)]
parts = [part for part in parts if len(part) >= 8]
if not parts:
parts = [text or "empty"]
# Bound pathological TeX fragments while retaining full benchmark coverage.
if len(parts) > 24:
parts = parts[:23] + [" ".join(parts[23:])]
return parts
def structural_risk(step: str, row_index: int, step_index: int) -> float:
"""Deterministic, non-learned proxy risk in [0.05, 0.95]."""
tokens = re.findall(r"[A-Za-z0-9]+|\\[A-Za-z]+", step)
equation_density = min(1.0, (step.count("=") + step.count("\\")) / 12.0)
length_term = min(1.0, len(tokens) / 80.0)
conclusion_bonus = 1.0 if "boxed" in step or "therefore" in step.lower() else 0.0
digest = hashlib.blake2b(
f"{row_index}:{step_index}:{step}".encode(), digest_size=8
).digest()
jitter = int.from_bytes(digest, "big") / (2**64 - 1)
score = 0.10 + 0.42 * length_term + 0.28 * equation_density - 0.10 * conclusion_bonus + 0.20 * jitter
return min(0.95, max(0.05, score))
def ancestors_for_chain(nodes: int) -> list[list[int]]:
return [list(range(node)) for node in range(nodes)]
def log_sigmoid(value: float) -> float:
if value >= 0:
return -math.log1p(math.exp(-value))
return value - math.log1p(math.exp(value))
def hard_prediction(risks: list[float], ancestors: list[list[int]], tau_alpha: float) -> list[int]:
tau_star = max(tau for tau in THRESHOLDS if tau < tau_alpha)
return [
int(risks[node] <= tau_star and all(risks[parent] <= tau_star for parent in ancestors[node]))
for node in range(len(risks))
]
def soft_prediction(risks: list[float], ancestors: list[list[int]], tau_alpha: float) -> tuple[list[int], float]:
# Theorem 3.2 schedule: a=1, b=3, beta=T^-a, tau_z=T^(ab).
beta = 1.0 / TEMPERATURE
tau_z = TEMPERATURE**3
log_weights = [
beta * tau + log_sigmoid((tau_alpha - tau - math.sqrt(tau_z)) / tau_z)
for tau in THRESHOLDS
]
maximum = max(log_weights)
weights = np.exp(np.asarray(log_weights) - maximum)
weights /= weights.sum()
memberships = []
for tau in THRESHOLDS:
probabilities = [
1.0 / (1.0 + math.exp(max(-700.0, min(700.0, (risk - tau) / TEMPERATURE))))
for risk in risks
]
coherent = []
for node, parent_nodes in enumerate(ancestors):
indices = [node] + parent_nodes
node_weights = [1.0] + [ANCESTOR_WEIGHT] * len(parent_nodes)
denominator = sum(node_weights)
coherent.append(
math.exp(
sum(
weight * math.log(max(probabilities[index], 1e-300))
for index, weight in zip(indices, node_weights)
)
/ denominator
)
)
memberships.append(coherent)
mixed = weights @ np.asarray(memberships)
return [int(value >= 0.5) for value in mixed], float(weights.max())
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--data", type=Path, default=Path("competition_math.parquet"))
parser.add_argument("--output", type=Path, default=Path("outputs/math_agreement_v1"))
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
table = pq.read_table(args.data, columns=["solution", "level", "type"])
records = table.to_pylist()
examples = []
all_risks = []
step_counts = []
for row_index, record in enumerate(records):
steps = split_steps(record["solution"])
risks = [structural_risk(step, row_index, step_index) for step_index, step in enumerate(steps)]
examples.append((risks, ancestors_for_chain(len(risks)), record["level"], record["type"]))
all_risks.extend(risks)
step_counts.append(len(steps))
output_rows = []
for alpha in ALPHAS:
# Quantile calibration is performed once over all public solution-step risks.
tau_alpha = float(np.quantile(np.asarray(all_risks), 1.0 - alpha, method="higher"))
# Ensure the prediction gate has at least one feasible threshold and is
# not exactly on a grid point, as in the theorem's separated regime.
tau_alpha = min(0.999, max(0.051, tau_alpha + 1e-7))
exact_matches = 0
node_matches = 0
node_total = 0
hard_retained = 0
soft_retained = 0
minimum_selected_mass = 1.0
for risks, ancestors, _, _ in examples:
hard = hard_prediction(risks, ancestors, tau_alpha)
soft, selected_mass = soft_prediction(risks, ancestors, tau_alpha)
exact_matches += int(hard == soft)
node_matches += sum(int(left == right) for left, right in zip(hard, soft))
node_total += len(hard)
hard_retained += sum(hard)
soft_retained += sum(soft)
minimum_selected_mass = min(minimum_selected_mass, selected_mass)
output_rows.append(
{
"alpha": alpha,
"calibrated_tau_alpha": tau_alpha,
"examples": len(examples),
"claim_nodes": node_total,
"exact_set_matches": exact_matches,
"exact_set_agreement_percent": 100.0 * exact_matches / len(examples),
"node_matches": node_matches,
"node_agreement_percent": 100.0 * node_matches / node_total,
"hard_retained_nodes": hard_retained,
"soft_retained_nodes": soft_retained,
"minimum_selected_threshold_mass": minimum_selected_mass,
}
)
with (args.output / "agreement_by_alpha.csv").open("w", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=list(output_rows[0]))
writer.writeheader()
writer.writerows(output_rows)
report = {
"claim": "DCF soft/hard agreement across alpha=0.01..0.10",
"scope": "benchmark-scale proxy; not the unreleased paper scorer/ADGs",
"dataset": DATASET,
"dataset_revision": DATASET_REVISION,
"dataset_file_sha256": sha256(args.data),
"examples": len(examples),
"solution_claim_nodes": sum(step_counts),
"step_count": {
"minimum": min(step_counts),
"median": float(np.median(step_counts)),
"maximum": max(step_counts),
},
"graph_construction": "sentence/TeX-step split; sequential chain with transitive ancestors",
"risk_proxy": "deterministic structural features plus pinned BLAKE2 jitter; not learned factuality",
"threshold_grid": list(THRESHOLDS),
"temperature": TEMPERATURE,
"ancestor_weight": ANCESTOR_WEIGHT,
"rows": output_rows,
"minimum_exact_set_agreement_percent": min(row["exact_set_agreement_percent"] for row in output_rows),
"maximum_exact_set_agreement_percent": max(row["exact_set_agreement_percent"] for row in output_rows),
"minimum_node_agreement_percent": min(row["node_agreement_percent"] for row in output_rows),
"maximum_node_agreement_percent": max(row["node_agreement_percent"] for row in output_rows),
}
(args.output / "summary.json").write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()