Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 43 additions & 21 deletions notebooks/affine_pz_twojet_adaquad_benchmarks.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@
" sys.path.insert(0, str(repo_root / \"src\"))\n",
"\n",
"\n",
"from intervalnets import Interval, IntervalTensor, PZIntegrationCell, enable_interval_eval, pz_sum_squares\n",
"from intervalnets.pz_integration import integrate_over_cell, _dorfler_marking as _pz_dorfler_marking, _evaluate_squared_contribution_cache, _interval_add, _interval_width, _split_box, _sqrt_interval_nonnegative\n",
"from intervalnets import Interval, IntervalTensor, PZIntegrationCell, enable_interval_eval\n",
"from intervalnets.pz_norms import pz_twojet_l2_integrand, pz_twojet_l2_norm, pz_twojet_w12_integrand, pz_twojet_w12_norm, pz_twojet_w22_integrand, pz_twojet_w22_norm\n",
"from intervalnets.pz_integration import integrate_over_cell, integrate_pz_twojet_squared, _dorfler_marking as _pz_dorfler_marking, _evaluate_squared_contribution_cache, _interval_add, _interval_width, _split_box, _sqrt_interval_nonnegative\n",
"from intervalnets.pytorch import _box_volume, _choose_split_dim, _dorfler_marking, _hessian_is_exact_zero, _interval_tensor_is_exact_constant, _jacobian_is_exact_zero, _lp_pointwise_power_bounds_refined, _sobolev_pointwise_power_bounds_refined\n",
"\n",
"enable_interval_eval(\"slope\")\n",
Expand Down Expand Up @@ -105,7 +106,9 @@
"metadata": {},
"source": [
"cell = PZIntegrationCell.from_affine_box(domain)\n",
"t0 = time.perf_counter()\n",
"jet = model.eval_pz_twojet(cell.domain)\n",
"twojet_construction_s = time.perf_counter() - t0\n",
"\n",
"from collections import Counter\n",
"\n",
Expand Down Expand Up @@ -143,26 +146,43 @@
"metadata": {},
"outputs": [],
"source": [
"## Direct PZ two-jet norm bounds\n",
"## Optimized PZ two-jet norm bounds and phase timings\n",
"\n",
"y_sq = pz_sum_squares(jet.Y)\n",
"j_sq = pz_sum_squares(jet.J)\n",
"h_sq = pz_sum_squares(jet.H)\n",
"l2_integrand = y_sq\n",
"w12_integrand = y_sq + j_sq\n",
"w22_integrand = w12_integrand + h_sq\n",
"# Keep the explicit squared PZ only as an optional regression/benchmark path.\n",
"EXPLICIT_COMPARISON = True\n",
"constructors = {\"L2\": pz_twojet_l2_integrand, \"W12\": pz_twojet_w12_integrand, \"W22\": pz_twojet_w22_integrand}\n",
"norms = {\"L2\": pz_twojet_l2_norm, \"W12\": pz_twojet_w12_norm, \"W22\": pz_twojet_w22_norm}\n",
"kinds = {\"L2\": \"l2\", \"W12\": \"w12\", \"W22\": \"w22\"}\n",
"\n",
"\n",
"def _cell_norm_from_cached_integrand(integrand):\n",
" integral = integrate_over_cell(integrand, cell, output=\"interval\")\n",
" return _sqrt_interval_nonnegative(integral)\n",
"\n",
"\n",
"direct_pz_norms = pd.DataFrame([\n",
" {\"quantity\": \"L2\", \"bounds\": _cell_norm_from_cached_integrand(l2_integrand)},\n",
" {\"quantity\": \"W12\", \"bounds\": _cell_norm_from_cached_integrand(w12_integrand)},\n",
" {\"quantity\": \"W22\", \"bounds\": _cell_norm_from_cached_integrand(w22_integrand)},\n",
"])\n",
"phase_rows = []\n",
"for quantity in [\"L2\", \"W12\", \"W22\"]:\n",
" explicit_integrand = None\n",
" explicit_integrand_s = explicit_integration_s = None\n",
" explicit_bounds = None\n",
" if EXPLICIT_COMPARISON:\n",
" t0 = time.perf_counter(); explicit_integrand = constructors[quantity](jet)\n",
" explicit_integrand_s = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); explicit_integral = integrate_over_cell(explicit_integrand, cell, output=\"interval\")\n",
" explicit_integration_s = time.perf_counter() - t0\n",
" explicit_bounds = _sqrt_interval_nonnegative(explicit_integral)\n",
"\n",
" t0 = time.perf_counter(); direct_integral = integrate_pz_twojet_squared(jet, cell, kinds[quantity])\n",
" direct_integrated_square_s = time.perf_counter() - t0\n",
" # This is the optimized public API used by applications.\n",
" bounds = norms[quantity](jet, cell)\n",
" if explicit_bounds is not None:\n",
" assert math.isclose(float(bounds.lower), float(explicit_bounds.lower), rel_tol=1e-11, abs_tol=1e-11)\n",
" assert math.isclose(float(bounds.upper), float(explicit_bounds.upper), rel_tol=1e-11, abs_tol=1e-11)\n",
" phase_rows.append({\n",
" \"quantity\": quantity, \"bounds\": bounds,\n",
" \"twojet_construction_s\": twojet_construction_s,\n",
" \"explicit_integrand_construction_s\": explicit_integrand_s,\n",
" \"direct_integrated_square_s\": direct_integrated_square_s,\n",
" \"explicit_integrand_integration_s\": explicit_integration_s,\n",
" \"total_cell_s\": twojet_construction_s + direct_integrated_square_s,\n",
" })\n",
"\n",
"direct_pz_norms = pd.DataFrame(phase_rows)\n",
"direct_pz_norms[\"lower\"] = direct_pz_norms[\"bounds\"].map(lambda z: float(z.lower))\n",
"direct_pz_norms[\"upper\"] = direct_pz_norms[\"bounds\"].map(lambda z: float(z.upper))\n",
"direct_pz_norms[\"width\"] = direct_pz_norms[\"upper\"] - direct_pz_norms[\"lower\"]\n",
Expand Down Expand Up @@ -285,7 +305,9 @@
"source": [
"## 6. Benchmark tables: width, runtime, cells, and refinement steps\n",
"\n",
"The following cell performs one cached adaptive run per `(method, quantity)` pair up to `MAX_REFINEMENT_STEPS`. Each row is the partial certified result after that many refinement steps from the same run.\n"
"The following cell performs one cached adaptive run per `(method, quantity)` pair up to `MAX_REFINEMENT_STEPS`. Each row is the partial certified result after that many refinement steps from the same run.\n",
"\n",
"`runtime_s` below is the separately accumulated adaptive runtime on the same model and domain; the per-cell construction/integrand/integration phases are reported above.\n"
]
},
{
Expand Down
182 changes: 182 additions & 0 deletions src/intervalnets/pz_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,24 @@
from __future__ import annotations

from dataclasses import dataclass
from itertools import product
from math import inf, isfinite, nextafter, prod, sqrt
from numbers import Real
from typing import Any, Literal, Sequence

from .interval import Interval
from .polynomial_zonotope import (
Exponent,
PZTwoJet,
PolynomialZonotope,
_abs_coeff,
_add_coeff,
_mul_coeff,
_merge_noise_kinds,
_to_fallback,
_zero_like,
box_monomial_moment,
torch,
)

try: # pragma: no cover - optional dependency
Expand Down Expand Up @@ -235,6 +240,183 @@ def integrate_over_cell(pz_expr: PolynomialZonotope, cell: PZIntegrationCell, *,
return integrate_pz_over_domain(weighted, cell.domain_noise_indices, mode="pointwise_interval").interval_enclosure()


TwoJetIntegrandKind = Literal["l2", "w12", "w22"]


def _scalar_coordinates(zonotope: PolynomialZonotope) -> list[PolynomialZonotope]:
"""Flatten a tensor-valued PZ without converting its scalar coefficients."""

if zonotope.shape == ():
return [zonotope]
return [zonotope[index] for index in product(*(range(size) for size in zonotope.shape))]


def _twojet_weighted_coordinates(
jet: PZTwoJet, integrand_kind: TwoJetIntegrandKind
) -> tuple[list[PolynomialZonotope], list[float]]:
if integrand_kind not in {"l2", "w12", "w22"}:
raise ValueError("integrand_kind must be 'l2', 'w12', or 'w22'.")

coordinates = _scalar_coordinates(jet.Y)
weights = [1.0] * len(coordinates)
if integrand_kind in {"w12", "w22"}:
jacobian = _scalar_coordinates(jet.J)
coordinates.extend(jacobian)
weights.extend([1.0] * len(jacobian))
if integrand_kind == "w22":
shape = jet.H.shape
if len(shape) == 3 and shape[1] == shape[2]:
output_indices: tuple[int | None, ...] = tuple(range(shape[0]))
input_dim = shape[1]
elif len(shape) == 2 and shape[0] == shape[1]:
output_indices = (None,)
input_dim = shape[0]
else:
raise ValueError("w22 direct integration requires a square stored Hessian.")
for output in output_indices:
for row in range(input_dim):
for column in range(row, input_dim):
index = (row, column) if output is None else (output, row, column)
coordinates.append(jet.H[index])
weights.append(1.0 if row == column else 2.0)
return coordinates, weights


def _validate_twojet_metadata(jet: PZTwoJet) -> tuple[int, tuple[str, ...]]:
components = (jet.Y, jet.J, jet.H)
num_noise = components[0].num_noise
if any(component.num_noise != num_noise for component in components[1:]):
raise ValueError("Y, J, and H must have identical num_noise metadata.")
noise_kinds = components[0].noise_kinds
for component in components[1:]:
noise_kinds = _merge_noise_kinds(noise_kinds, component.noise_kinds)
return num_noise, noise_kinds


def _coefficient_matrix(
coordinates: Sequence[PolynomialZonotope], union_support: Sequence[Exponent]
) -> tuple[Any, Any]:
centers = [coordinate.center for coordinate in coordinates]
matrix = [
[coordinate.terms.get(exponent, _zero_like(coordinate.center)) for coordinate in coordinates]
for exponent in union_support
]
if torch is not None and isinstance(centers[0], torch.Tensor):
center_vector = torch.stack(centers)
if matrix:
return center_vector, torch.stack([torch.stack(row) for row in matrix])
return center_vector, torch.empty((0, len(centers)), dtype=center_vector.dtype, device=center_vector.device)
return centers, matrix


def _weighted_dot(left: Sequence[Any], right: Sequence[Any], weights: Sequence[float]):
result = _zero_like(left[0])
for lhs, rhs, weight in zip(left, right, weights):
result = _add_coeff(result, _mul_coeff(_mul_coeff(lhs, rhs), weight))
return result


def integrate_pz_twojet_squared(
jet: PZTwoJet, cell: PZIntegrationCell, integrand_kind: TwoJetIntegrandKind
):
Comment on lines +319 to +321

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route adaptive norm cells through the direct integrator

When applications call model.pz_l2norm(...) or model.pz_sobolev_norm(...), _pz_adaptive_squared_integral still reaches _evaluate_squared_contribution_cache, which constructs _squared_twojet_integrand(jet, integrand_kind) and passes it to integrate_over_cell; it never calls this new helper. Consequently, the primary adaptive API—and the notebook's PZ API/trace benchmarks—continues materializing the expensive squared PZ for every active cell, so the intended optimization is absent from the path where repeated Hessian convolution matters most.

AGENTS.md reference: AGENTS.md:L45-L47

Useful? React with 👍 / 👎.

"""Directly integrate a squared two-jet over a supported affine cell.

Unsupported densities and Hessian layouts deliberately use the explicit
squared-integrand reference pipeline.
"""

def explicit_fallback():
from .pz_norms import (
pz_twojet_l2_integrand,
pz_twojet_w12_integrand,
pz_twojet_w22_integrand,
)

constructor = {
"l2": pz_twojet_l2_integrand,
"w12": pz_twojet_w12_integrand,
"w22": pz_twojet_w22_integrand,
}.get(integrand_kind)
if constructor is None:
raise ValueError("integrand_kind must be 'l2', 'w12', or 'w22'.")
return integrate_over_cell(constructor(jet), cell, output="interval")

density = cell.jacobian_density
if not isinstance(density, Real) or not isfinite(float(density)) or float(density) < 0.0:
return explicit_fallback()

num_noise, noise_kinds = _validate_twojet_metadata(jet)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate only components used by the selected norm

When a manually constructed PZTwoJet has valid Y metadata but unused J or H components with different num_noise metadata, pz_twojet_l2_norm(jet, cell) now raises here even though the previous explicit L2 path only squared Y and succeeded; the same problem affects W12 when only H differs. Validate metadata after selecting the coordinates, or limit validation to the components required by integrand_kind, so supplying a cell does not change otherwise supported public behavior.

AGENTS.md reference: AGENTS.md:L30-L32

Useful? React with 👍 / 👎.

try:
coordinates, weights = _twojet_weighted_coordinates(jet, integrand_kind)
except ValueError as error:
if "Hessian" in str(error):
return explicit_fallback()
raise
if not coordinates:
return explicit_fallback()

domain_indices = tuple(int(index) for index in cell.domain_noise_indices)
if len(set(domain_indices)) != len(domain_indices) or any(index < 0 or index >= num_noise for index in domain_indices):
raise ValueError("cell domain noise index out of range or duplicated.")
domain_set = set(domain_indices)
retained_indices = tuple(index for index in range(num_noise) if index not in domain_set)
retained_kinds = tuple(noise_kinds[index] for index in retained_indices)
pointwise_indices = tuple(index for index, kind in enumerate(noise_kinds) if kind in POINTWISE_RESIDUAL_KINDS)
measure = float(2 ** len(domain_indices))
scale = float(density)

support = sorted(set().union(*(coordinate.terms for coordinate in coordinates)))
centers, matrix = _coefficient_matrix(coordinates, support)
if torch is not None and isinstance(centers, torch.Tensor):
weight_vector = torch.tensor(weights, dtype=centers.dtype, device=centers.device)
weighted_matrix = matrix * weight_vector.unsqueeze(0)
center_cross = weighted_matrix @ centers
gram = weighted_matrix @ matrix.T
center_square = torch.dot(centers * weight_vector, centers)
else:
center_cross = [_weighted_dot(row, centers, weights) for row in matrix]
gram = [[_weighted_dot(left, right, weights) for right in matrix] for left in matrix]
center_square = _weighted_dot(centers, centers, weights)

retained: dict[Exponent, Any] = {}
pointwise: dict[Exponent, Any] = {}
zero_retained = (0,) * len(retained_indices)

def accumulate(target: dict[Exponent, Any], exponent: Exponent, coefficient: Any) -> None:
target[exponent] = _add_coeff(target[exponent], coefficient) if exponent in target else coefficient

def route(exponent: Exponent, coefficient: Any) -> None:
scaled = _mul_coeff(coefficient, scale)
if any(exponent[index] for index in pointwise_indices):
accumulate(pointwise, exponent, scaled)
return
moment = box_monomial_moment(tuple(exponent[index] for index in domain_indices))
if moment == 0.0:
return
retained_exponent = tuple(exponent[index] for index in retained_indices)
accumulate(retained, retained_exponent, _mul_coeff(scaled, moment))

route((0,) * num_noise, center_square)
for index, exponent in enumerate(support):
route(exponent, _mul_coeff(center_cross[index], 2.0))
for other_index in range(index, len(support)):
pair_exponent = tuple(a + b for a, b in zip(exponent, support[other_index]))
factor = 1.0 if index == other_index else 2.0
route(pair_exponent, _mul_coeff(gram[index][other_index], factor))

center = retained.pop(zero_retained, _zero_like(centers[0]))
radius = _zero_like(center)
for coefficient in pointwise.values():
radius = _add_coeff(radius, _mul_coeff(_abs_coeff(coefficient), measure))
result = IntegratedPZResult(
polynomial=PolynomialZonotope(center, retained, num_noise=len(retained_indices), noise_kinds=retained_kinds),
interval_radius=radius,
measure=measure,
metadata={"mode": "pointwise_interval", "direct_twojet_squared": True, "integrand_kind": integrand_kind},
)
return result.interval_enclosure()


def _require_interval_tensor_domain(domain: Any):
from .pytorch import IntervalTensor as RuntimeIntervalTensor

Expand Down
17 changes: 13 additions & 4 deletions src/intervalnets/pz_norms.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from .interval import Interval
from .polynomial_zonotope import PZTwoJet, PolynomialZonotope, pz_to_latex, pz_to_markdown_code, twojet_to_latex
from .pz_integration import PZIntegrationCell, integrate_over_cell, integrate_pz_over_domain
from .pz_integration import PZIntegrationCell, integrate_over_cell, integrate_pz_over_domain, integrate_pz_twojet_squared

try: # pragma: no cover - optional dependency
import torch
Expand Down Expand Up @@ -212,12 +212,21 @@ def pz_norm_from_integrand(


def pz_twojet_l2_norm(jet: PZTwoJet, cell: PZIntegrationCell | None = None, *, p: float = 2.0) -> Interval:
return pz_norm_from_integrand(pz_twojet_l2_integrand(jet), cell, p=p)
_require_p2(p)
if cell is not None:
return _sqrt_interval_nonnegative(integrate_pz_twojet_squared(jet, cell, "l2"))
return pz_norm_from_integrand(pz_twojet_l2_integrand(jet), p=p)


def pz_twojet_w12_norm(jet: PZTwoJet, cell: PZIntegrationCell | None = None, *, p: float = 2.0) -> Interval:
return pz_norm_from_integrand(pz_twojet_w12_integrand(jet), cell, p=p)
_require_p2(p)
if cell is not None:
return _sqrt_interval_nonnegative(integrate_pz_twojet_squared(jet, cell, "w12"))
return pz_norm_from_integrand(pz_twojet_w12_integrand(jet), p=p)


def pz_twojet_w22_norm(jet: PZTwoJet, cell: PZIntegrationCell | None = None, *, p: float = 2.0) -> Interval:
return pz_norm_from_integrand(pz_twojet_w22_integrand(jet), cell, p=p)
_require_p2(p)
if cell is not None:
return _sqrt_interval_nonnegative(integrate_pz_twojet_squared(jet, cell, "w22"))
return pz_norm_from_integrand(pz_twojet_w22_integrand(jet), p=p)
Loading
Loading