Buckets:
| #!/usr/bin/env python3 | |
| """Scaled reproduction of the synthetic GMM experiment in Section 4. | |
| The script intentionally depends only on the Python standard library and NumPy. | |
| PyTorch is imported only when ``--backend torch`` is requested. Each trial uses | |
| the same data and the same balanced random partition for Lloyd and Hartigan. | |
| Examples | |
| -------- | |
| Local smoke run:: | |
| python reproduce_section4.py --profile smoke --backend numpy \ | |
| --output-dir artifacts/section4-smoke | |
| Single-GPU full run:: | |
| python reproduce_section4.py --profile full --backend torch --device cuda \ | |
| --output-dir artifacts/section4-full | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| import datetime as dt | |
| import hashlib | |
| import html | |
| import json | |
| import math | |
| import os | |
| import platform | |
| import sys | |
| from pathlib import Path | |
| from typing import Any, Iterable, Sequence | |
| import numpy as np | |
| PROFILES = { | |
| # Deliberately small enough for a laptop while still crossing sigma^2 > n | |
| # and spanning two orders of magnitude in dimension. | |
| "smoke": { | |
| "dimensions": (20, 200, 2_000, 10_000), | |
| "noise_variances": (1.0, 100.0), | |
| "trials": 4, | |
| }, | |
| # A scaled, substantive version of the official 20 x 20 x 100 grid. This | |
| # retains the decisive regimes and the paper's largest dimension. | |
| "full": { | |
| "dimensions": (10, 30, 100, 300, 1_000, 3_000, 10_000), | |
| "noise_variances": (1.0, 10.0, 50.0, 100.0), | |
| "trials": 50, | |
| }, | |
| } | |
| RAW_FIELDS = ( | |
| "profile", | |
| "backend", | |
| "device", | |
| "dtype", | |
| "seed", | |
| "trial_seed", | |
| "trial", | |
| "k", | |
| "n", | |
| "dimension", | |
| "tau2", | |
| "sigma2", | |
| "high_noise_sigma2_gt_n", | |
| "init_nmi", | |
| "lloyd_nmi", | |
| "hartigan_nmi", | |
| "hartigan_minus_lloyd_nmi", | |
| "init_sse", | |
| "true_partition_sse", | |
| "lloyd_sse", | |
| "hartigan_sse", | |
| "lloyd_sse_over_true", | |
| "hartigan_sse_over_true", | |
| "init_lloyd_fixed", | |
| "init_hartigan_fixed", | |
| "lloyd_unchanged_from_init", | |
| "hartigan_unchanged_from_init", | |
| "lloyd_changed_fraction", | |
| "hartigan_changed_fraction", | |
| "lloyd_iterations", | |
| "hartigan_sweeps", | |
| "lloyd_converged", | |
| "hartigan_converged", | |
| "hartigan_fixed_is_lloyd_fixed", | |
| ) | |
| SUMMARY_FIELDS = ( | |
| "k", | |
| "n", | |
| "dimension", | |
| "tau2", | |
| "sigma2", | |
| "high_noise_sigma2_gt_n", | |
| "trials", | |
| "init_nmi_mean", | |
| "lloyd_nmi_mean", | |
| "lloyd_nmi_sd", | |
| "lloyd_nmi_se", | |
| "hartigan_nmi_mean", | |
| "hartigan_nmi_sd", | |
| "hartigan_nmi_se", | |
| "hartigan_minus_lloyd_nmi_mean", | |
| "init_lloyd_fixed_rate", | |
| "init_hartigan_fixed_rate", | |
| "lloyd_unchanged_rate", | |
| "hartigan_unchanged_rate", | |
| "lloyd_changed_fraction_mean", | |
| "hartigan_changed_fraction_mean", | |
| "lloyd_recovery_rate_nmi_ge_0_9", | |
| "hartigan_recovery_rate_nmi_ge_0_9", | |
| "lloyd_sse_over_true_mean", | |
| "hartigan_sse_over_true_mean", | |
| "lloyd_iterations_mean", | |
| "hartigan_sweeps_mean", | |
| "lloyd_converged_rate", | |
| "hartigan_converged_rate", | |
| "hartigan_fixed_is_lloyd_fixed_rate", | |
| ) | |
| def _parse_int_list(value: str) -> tuple[int, ...]: | |
| try: | |
| parsed = tuple(int(piece.strip()) for piece in value.split(",") if piece.strip()) | |
| except ValueError as exc: | |
| raise argparse.ArgumentTypeError("expected comma-separated integers") from exc | |
| if not parsed or any(item <= 0 for item in parsed): | |
| raise argparse.ArgumentTypeError("all dimensions must be positive") | |
| return parsed | |
| def _parse_float_list(value: str) -> tuple[float, ...]: | |
| try: | |
| parsed = tuple(float(piece.strip()) for piece in value.split(",") if piece.strip()) | |
| except ValueError as exc: | |
| raise argparse.ArgumentTypeError("expected comma-separated numbers") from exc | |
| if not parsed or any((not math.isfinite(item)) or item <= 0 for item in parsed): | |
| raise argparse.ArgumentTypeError("all noise variances must be finite and positive") | |
| return parsed | |
| def _trial_seed( | |
| master_seed: int, k: int, n: int, dimension: int, sigma2: float, trial: int | |
| ) -> int: | |
| """Grid-order- and batch-size-independent seed for one paired trial.""" | |
| key = f"section4|{master_seed}|{k}|{n}|{dimension}|{sigma2:.17g}|{trial}" | |
| digest = hashlib.blake2b(key.encode("ascii"), digest_size=8).digest() | |
| return int.from_bytes(digest, "little", signed=False) | |
| def _make_trial( | |
| *, | |
| master_seed: int, | |
| trial: int, | |
| k: int, | |
| n: int, | |
| dimension: int, | |
| tau2: float, | |
| sigma2: float, | |
| dtype: np.dtype[Any], | |
| ) -> tuple[np.ndarray, np.ndarray, np.ndarray, int]: | |
| seed = _trial_seed(master_seed, k, n, dimension, sigma2, trial) | |
| rng = np.random.default_rng(seed) | |
| true_labels = np.repeat(np.arange(k, dtype=np.int64), n // k) | |
| centers = rng.standard_normal((k, dimension), dtype=dtype) | |
| centers *= np.asarray(math.sqrt(tau2), dtype=dtype) | |
| noise = rng.standard_normal((n, dimension), dtype=dtype) | |
| noise *= np.asarray(math.sqrt(sigma2), dtype=dtype) | |
| data = centers[true_labels] + noise | |
| # This is the official random-partition construction: permute the balanced | |
| # true-label multiset. Both algorithms receive these exact labels. | |
| initial_labels = rng.permutation(true_labels) | |
| return data, true_labels, initial_labels, seed | |
| def _make_batch( | |
| trial_indices: Sequence[int], | |
| *, | |
| master_seed: int, | |
| k: int, | |
| n: int, | |
| dimension: int, | |
| tau2: float, | |
| sigma2: float, | |
| dtype: np.dtype[Any], | |
| ) -> tuple[np.ndarray, np.ndarray, np.ndarray, list[int]]: | |
| data = np.empty((len(trial_indices), n, dimension), dtype=dtype) | |
| true_labels = np.empty((len(trial_indices), n), dtype=np.int64) | |
| initial_labels = np.empty_like(true_labels) | |
| seeds: list[int] = [] | |
| for row, trial in enumerate(trial_indices): | |
| x, y, initial, seed = _make_trial( | |
| master_seed=master_seed, | |
| trial=trial, | |
| k=k, | |
| n=n, | |
| dimension=dimension, | |
| tau2=tau2, | |
| sigma2=sigma2, | |
| dtype=dtype, | |
| ) | |
| data[row] = x | |
| true_labels[row] = y | |
| initial_labels[row] = initial | |
| seeds.append(seed) | |
| expected = n // k | |
| counts = np.stack([(initial_labels == cluster).sum(axis=1) for cluster in range(k)], axis=1) | |
| assert np.all(counts == expected), "initial random partitions must remain balanced" | |
| return data, true_labels, initial_labels, seeds | |
| def normalized_mutual_information(labels_a: np.ndarray, labels_b: np.ndarray) -> float: | |
| """Arithmetic-mean normalized mutual information, without scikit-learn.""" | |
| a = np.asarray(labels_a).reshape(-1) | |
| b = np.asarray(labels_b).reshape(-1) | |
| if a.size != b.size or a.size == 0: | |
| raise ValueError("NMI inputs must have equal non-zero length") | |
| _, a_inverse = np.unique(a, return_inverse=True) | |
| _, b_inverse = np.unique(b, return_inverse=True) | |
| contingency = np.zeros( | |
| (int(a_inverse.max()) + 1, int(b_inverse.max()) + 1), dtype=np.float64 | |
| ) | |
| np.add.at(contingency, (a_inverse, b_inverse), 1.0) | |
| contingency /= float(a.size) | |
| p_a = contingency.sum(axis=1) | |
| p_b = contingency.sum(axis=0) | |
| nz_i, nz_j = np.nonzero(contingency) | |
| cells = contingency[nz_i, nz_j] | |
| mutual_information = float( | |
| np.sum(cells * np.log(cells / (p_a[nz_i] * p_b[nz_j]))) | |
| ) | |
| h_a = float(-np.sum(p_a[p_a > 0] * np.log(p_a[p_a > 0]))) | |
| h_b = float(-np.sum(p_b[p_b > 0] * np.log(p_b[p_b > 0]))) | |
| denominator = h_a + h_b | |
| if denominator == 0.0: | |
| return 1.0 | |
| value = 2.0 * mutual_information / denominator | |
| # Roundoff can produce values a few ulps outside the mathematical range. | |
| return float(min(1.0, max(0.0, value))) | |
| # --------------------------------------------------------------------------- | |
| # NumPy implementation | |
| def _centers_numpy( | |
| data: np.ndarray, labels: np.ndarray, k: int | |
| ) -> tuple[np.ndarray, np.ndarray]: | |
| batch, _, dimension = data.shape | |
| centers = np.zeros((batch, k, dimension), dtype=data.dtype) | |
| counts = np.zeros((batch, k), dtype=np.int64) | |
| for cluster in range(k): | |
| mask = labels == cluster | |
| counts[:, cluster] = mask.sum(axis=1) | |
| centers[:, cluster] = (data * mask[:, :, None]).sum(axis=1) | |
| centers[:, cluster] /= np.maximum(counts[:, cluster], 1)[:, None] | |
| return centers, counts | |
| def _assign_numpy(data: np.ndarray, centers: np.ndarray) -> np.ndarray: | |
| squared_distances = np.sum( | |
| (data[:, :, None, :] - centers[:, None, :, :]) ** 2, axis=-1 | |
| ) | |
| return np.argmin(squared_distances, axis=2).astype(np.int64, copy=False) | |
| def _loss_numpy(data: np.ndarray, labels: np.ndarray, k: int) -> np.ndarray: | |
| centers, _ = _centers_numpy(data, labels, k) | |
| batch_indices = np.arange(data.shape[0])[:, None] | |
| residual = data - centers[batch_indices, labels] | |
| return np.sum(residual * residual, axis=(1, 2), dtype=np.float64) | |
| def _lloyd_numpy( | |
| data: np.ndarray, initial_labels: np.ndarray, k: int, max_sweeps: int | |
| ) -> dict[str, np.ndarray]: | |
| labels = initial_labels.copy() | |
| active = np.ones(data.shape[0], dtype=bool) | |
| iterations = np.zeros(data.shape[0], dtype=np.int64) | |
| initial_fixed = np.zeros(data.shape[0], dtype=bool) | |
| for sweep in range(max_sweeps): | |
| centers, _ = _centers_numpy(data, labels, k) | |
| proposed = _assign_numpy(data, centers) | |
| proposed[~active] = labels[~active] | |
| changed = np.any(proposed != labels, axis=1) | |
| if sweep == 0: | |
| initial_fixed = ~changed | |
| iterations[active] += 1 | |
| labels[active] = proposed[active] | |
| active &= changed | |
| if not np.any(active): | |
| break | |
| centers, _ = _centers_numpy(data, labels, k) | |
| is_fixed = np.all(_assign_numpy(data, centers) == labels, axis=1) | |
| return { | |
| "labels": labels, | |
| "iterations": iterations, | |
| "converged": ~active, | |
| "initial_fixed": initial_fixed, | |
| "unchanged": np.all(labels == initial_labels, axis=1), | |
| "changed_fraction": np.mean(labels != initial_labels, axis=1), | |
| "sse": _loss_numpy(data, labels, k), | |
| "is_fixed": is_fixed, | |
| } | |
| def _hartigan_numpy( | |
| data: np.ndarray, initial_labels: np.ndarray, k: int, max_sweeps: int | |
| ) -> dict[str, np.ndarray]: | |
| """Hartigan relocation with immediate center/count updates in sample order.""" | |
| batch, n, _ = data.shape | |
| labels = initial_labels.copy() | |
| active = np.ones(batch, dtype=bool) | |
| iterations = np.zeros(batch, dtype=np.int64) | |
| initial_fixed = np.zeros(batch, dtype=bool) | |
| rows = np.arange(batch) | |
| for sweep in range(max_sweeps): | |
| before = labels.copy() | |
| active_at_start = active.copy() | |
| # Recomputing once per sweep prevents accumulated floating-point drift; | |
| # every accepted relocation below still updates means immediately. | |
| centers, counts = _centers_numpy(data, labels, k) | |
| for sample in range(n): | |
| old = labels[:, sample].copy() | |
| point = data[:, sample] | |
| squared_distances = np.sum( | |
| (point[:, None, :] - centers) ** 2, axis=2 | |
| ) | |
| count_float = counts.astype(data.dtype, copy=False) | |
| scores = squared_distances * count_float / (count_float + 1.0) | |
| old_count = counts[rows, old] | |
| source_distance = squared_distances[rows, old] | |
| source_score = np.where( | |
| old_count <= 1, | |
| -1.0, | |
| source_distance * old_count / np.maximum(old_count - 1, 1), | |
| ) | |
| scores[rows, old] = source_score | |
| new = np.argmin(scores, axis=1).astype(np.int64, copy=False) | |
| new[~active_at_start] = old[~active_at_start] | |
| moved = active_at_start & (new != old) | |
| if np.any(moved): | |
| old_center = centers[rows, old].copy() | |
| new_center = centers[rows, new].copy() | |
| old_count_float = old_count.astype(data.dtype, copy=False) | |
| new_count = counts[rows, new] | |
| new_count_float = new_count.astype(data.dtype, copy=False) | |
| updated_old = ( | |
| old_center * old_count_float[:, None] - point | |
| ) / np.maximum(old_count_float - 1.0, 1.0)[:, None] | |
| updated_new = ( | |
| new_center * new_count_float[:, None] + point | |
| ) / (new_count_float + 1.0)[:, None] | |
| moved_rows = rows[moved] | |
| centers[moved_rows, old[moved]] = updated_old[moved] | |
| centers[moved_rows, new[moved]] = updated_new[moved] | |
| counts[moved_rows, old[moved]] -= 1 | |
| counts[moved_rows, new[moved]] += 1 | |
| labels[:, sample] = new | |
| changed = np.any(labels != before, axis=1) | |
| if sweep == 0: | |
| initial_fixed = ~changed | |
| iterations[active_at_start] += 1 | |
| active &= changed | |
| if not np.any(active): | |
| break | |
| centers, _ = _centers_numpy(data, labels, k) | |
| lloyd_fixed = np.all(_assign_numpy(data, centers) == labels, axis=1) | |
| return { | |
| "labels": labels, | |
| "iterations": iterations, | |
| "converged": ~active, | |
| "initial_fixed": initial_fixed, | |
| "unchanged": np.all(labels == initial_labels, axis=1), | |
| "changed_fraction": np.mean(labels != initial_labels, axis=1), | |
| "sse": _loss_numpy(data, labels, k), | |
| "lloyd_fixed": lloyd_fixed, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Optional PyTorch implementation. No torch symbol is imported at module load. | |
| def _centers_torch(data: Any, labels: Any, k: int, torch: Any) -> tuple[Any, Any]: | |
| sums = [] | |
| counts = [] | |
| for cluster in range(k): | |
| mask = labels == cluster | |
| count = mask.sum(dim=1) | |
| counts.append(count) | |
| sums.append((data * mask.unsqueeze(2)).sum(dim=1)) | |
| count_tensor = torch.stack(counts, dim=1) | |
| centers = torch.stack(sums, dim=1) | |
| centers = centers / count_tensor.clamp_min(1).unsqueeze(2).to(data.dtype) | |
| return centers, count_tensor | |
| def _assign_torch(data: Any, centers: Any, torch: Any) -> Any: | |
| squared_distances = ((data.unsqueeze(2) - centers.unsqueeze(1)) ** 2).sum(dim=3) | |
| return torch.argmin(squared_distances, dim=2) | |
| def _loss_torch(data: Any, labels: Any, k: int, torch: Any) -> Any: | |
| centers, _ = _centers_torch(data, labels, k, torch) | |
| rows = torch.arange(data.shape[0], device=data.device).unsqueeze(1) | |
| residual = data - centers[rows, labels] | |
| # Summation remains on-device; conversion to float64 happens only for the | |
| # small vector of per-trial results. | |
| return (residual * residual).sum(dim=(1, 2)).to(torch.float64) | |
| def _lloyd_torch( | |
| data: Any, initial_labels: Any, k: int, max_sweeps: int, torch: Any | |
| ) -> dict[str, Any]: | |
| labels = initial_labels.clone() | |
| active = torch.ones(data.shape[0], dtype=torch.bool, device=data.device) | |
| iterations = torch.zeros(data.shape[0], dtype=torch.int64, device=data.device) | |
| initial_fixed = torch.zeros_like(active) | |
| for sweep in range(max_sweeps): | |
| centers, _ = _centers_torch(data, labels, k, torch) | |
| proposed = _assign_torch(data, centers, torch) | |
| proposed = torch.where(active.unsqueeze(1), proposed, labels) | |
| changed = torch.any(proposed != labels, dim=1) | |
| if sweep == 0: | |
| initial_fixed = ~changed | |
| iterations += active.to(torch.int64) | |
| labels = torch.where(active.unsqueeze(1), proposed, labels) | |
| active = active & changed | |
| if not bool(torch.any(active).item()): | |
| break | |
| centers, _ = _centers_torch(data, labels, k, torch) | |
| is_fixed = torch.all(_assign_torch(data, centers, torch) == labels, dim=1) | |
| return { | |
| "labels": labels, | |
| "iterations": iterations, | |
| "converged": ~active, | |
| "initial_fixed": initial_fixed, | |
| "unchanged": torch.all(labels == initial_labels, dim=1), | |
| "changed_fraction": (labels != initial_labels).to(torch.float64).mean(dim=1), | |
| "sse": _loss_torch(data, labels, k, torch), | |
| "is_fixed": is_fixed, | |
| } | |
| def _hartigan_torch( | |
| data: Any, initial_labels: Any, k: int, max_sweeps: int, torch: Any | |
| ) -> dict[str, Any]: | |
| batch, n, _ = data.shape | |
| labels = initial_labels.clone() | |
| active = torch.ones(batch, dtype=torch.bool, device=data.device) | |
| iterations = torch.zeros(batch, dtype=torch.int64, device=data.device) | |
| initial_fixed = torch.zeros_like(active) | |
| rows = torch.arange(batch, device=data.device) | |
| for sweep in range(max_sweeps): | |
| before = labels.clone() | |
| active_at_start = active.clone() | |
| centers, counts = _centers_torch(data, labels, k, torch) | |
| for sample in range(n): | |
| old = labels[:, sample].clone() | |
| point = data[:, sample] | |
| squared_distances = ((point.unsqueeze(1) - centers) ** 2).sum(dim=2) | |
| count_float = counts.to(data.dtype) | |
| scores = squared_distances * count_float / (count_float + 1.0) | |
| old_count = counts[rows, old] | |
| source_distance = squared_distances[rows, old] | |
| source_score = torch.where( | |
| old_count <= 1, | |
| torch.full_like(source_distance, -1.0), | |
| source_distance | |
| * old_count.to(data.dtype) | |
| / (old_count - 1).clamp_min(1).to(data.dtype), | |
| ) | |
| scores[rows, old] = source_score | |
| new = torch.argmin(scores, dim=1) | |
| new = torch.where(active_at_start, new, old) | |
| moved = active_at_start & (new != old) | |
| # Empty advanced-index assignments are valid, so this stays fully | |
| # asynchronous on CUDA instead of synchronizing once per sample. | |
| old_center = centers[rows, old].clone() | |
| new_center = centers[rows, new].clone() | |
| old_count_float = old_count.to(data.dtype) | |
| new_count = counts[rows, new] | |
| new_count_float = new_count.to(data.dtype) | |
| updated_old = ( | |
| old_center * old_count_float.unsqueeze(1) - point | |
| ) / (old_count_float - 1.0).clamp_min(1.0).unsqueeze(1) | |
| updated_new = ( | |
| new_center * new_count_float.unsqueeze(1) + point | |
| ) / (new_count_float + 1.0).unsqueeze(1) | |
| moved_rows = rows[moved] | |
| centers[moved_rows, old[moved]] = updated_old[moved] | |
| centers[moved_rows, new[moved]] = updated_new[moved] | |
| counts[moved_rows, old[moved]] -= 1 | |
| counts[moved_rows, new[moved]] += 1 | |
| labels[:, sample] = new | |
| changed = torch.any(labels != before, dim=1) | |
| if sweep == 0: | |
| initial_fixed = ~changed | |
| iterations += active_at_start.to(torch.int64) | |
| active = active & changed | |
| if not bool(torch.any(active).item()): | |
| break | |
| centers, _ = _centers_torch(data, labels, k, torch) | |
| lloyd_fixed = torch.all(_assign_torch(data, centers, torch) == labels, dim=1) | |
| return { | |
| "labels": labels, | |
| "iterations": iterations, | |
| "converged": ~active, | |
| "initial_fixed": initial_fixed, | |
| "unchanged": torch.all(labels == initial_labels, dim=1), | |
| "changed_fraction": (labels != initial_labels).to(torch.float64).mean(dim=1), | |
| "sse": _loss_torch(data, labels, k, torch), | |
| "lloyd_fixed": lloyd_fixed, | |
| } | |
| def _torch_result_to_numpy(result: dict[str, Any]) -> dict[str, np.ndarray]: | |
| return {key: value.detach().cpu().numpy() for key, value in result.items()} | |
| def _mean(rows: Sequence[dict[str, Any]], field: str) -> float: | |
| return float(np.mean([float(row[field]) for row in rows], dtype=np.float64)) | |
| def _sample_sd(rows: Sequence[dict[str, Any]], field: str) -> float: | |
| values = np.asarray([float(row[field]) for row in rows], dtype=np.float64) | |
| return float(np.std(values, ddof=1)) if len(values) > 1 else 0.0 | |
| def _aggregate(raw_rows: Sequence[dict[str, Any]]) -> list[dict[str, Any]]: | |
| groups: dict[tuple[int, int, int, float, float], list[dict[str, Any]]] = {} | |
| for row in raw_rows: | |
| key = ( | |
| int(row["k"]), | |
| int(row["n"]), | |
| int(row["dimension"]), | |
| float(row["tau2"]), | |
| float(row["sigma2"]), | |
| ) | |
| groups.setdefault(key, []).append(row) | |
| output: list[dict[str, Any]] = [] | |
| for key in sorted(groups, key=lambda item: (item[4], item[2], item[0])): | |
| rows = groups[key] | |
| k, n, dimension, tau2, sigma2 = key | |
| lloyd_sd = _sample_sd(rows, "lloyd_nmi") | |
| hartigan_sd = _sample_sd(rows, "hartigan_nmi") | |
| count = len(rows) | |
| output.append( | |
| { | |
| "k": k, | |
| "n": n, | |
| "dimension": dimension, | |
| "tau2": tau2, | |
| "sigma2": sigma2, | |
| "high_noise_sigma2_gt_n": int(sigma2 > n), | |
| "trials": count, | |
| "init_nmi_mean": _mean(rows, "init_nmi"), | |
| "lloyd_nmi_mean": _mean(rows, "lloyd_nmi"), | |
| "lloyd_nmi_sd": lloyd_sd, | |
| "lloyd_nmi_se": lloyd_sd / math.sqrt(count), | |
| "hartigan_nmi_mean": _mean(rows, "hartigan_nmi"), | |
| "hartigan_nmi_sd": hartigan_sd, | |
| "hartigan_nmi_se": hartigan_sd / math.sqrt(count), | |
| "hartigan_minus_lloyd_nmi_mean": _mean( | |
| rows, "hartigan_minus_lloyd_nmi" | |
| ), | |
| "init_lloyd_fixed_rate": _mean(rows, "init_lloyd_fixed"), | |
| "init_hartigan_fixed_rate": _mean(rows, "init_hartigan_fixed"), | |
| "lloyd_unchanged_rate": _mean(rows, "lloyd_unchanged_from_init"), | |
| "hartigan_unchanged_rate": _mean(rows, "hartigan_unchanged_from_init"), | |
| "lloyd_changed_fraction_mean": _mean(rows, "lloyd_changed_fraction"), | |
| "hartigan_changed_fraction_mean": _mean(rows, "hartigan_changed_fraction"), | |
| "lloyd_recovery_rate_nmi_ge_0_9": float( | |
| np.mean([float(row["lloyd_nmi"]) >= 0.9 for row in rows]) | |
| ), | |
| "hartigan_recovery_rate_nmi_ge_0_9": float( | |
| np.mean([float(row["hartigan_nmi"]) >= 0.9 for row in rows]) | |
| ), | |
| "lloyd_sse_over_true_mean": _mean(rows, "lloyd_sse_over_true"), | |
| "hartigan_sse_over_true_mean": _mean(rows, "hartigan_sse_over_true"), | |
| "lloyd_iterations_mean": _mean(rows, "lloyd_iterations"), | |
| "hartigan_sweeps_mean": _mean(rows, "hartigan_sweeps"), | |
| "lloyd_converged_rate": _mean(rows, "lloyd_converged"), | |
| "hartigan_converged_rate": _mean(rows, "hartigan_converged"), | |
| "hartigan_fixed_is_lloyd_fixed_rate": _mean( | |
| rows, "hartigan_fixed_is_lloyd_fixed" | |
| ), | |
| } | |
| ) | |
| return output | |
| def _write_csv(path: Path, rows: Sequence[dict[str, Any]], fields: Sequence[str]) -> None: | |
| with path.open("w", newline="", encoding="utf-8") as handle: | |
| writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore") | |
| writer.writeheader() | |
| writer.writerows(rows) | |
| def _polyline(points: Iterable[tuple[float, float]], css_class: str) -> str: | |
| coords = " ".join(f"{x:.2f},{y:.2f}" for x, y in points) | |
| return f'<polyline class="{css_class}" points="{coords}" />' | |
| def _make_svg(summary: Sequence[dict[str, Any]]) -> str: | |
| noises = sorted({float(row["sigma2"]) for row in summary}) | |
| panel_width = 270 | |
| left = 72 | |
| width = left + panel_width * len(noises) + 25 | |
| height = 650 | |
| top_y0, top_y1 = 85.0, 300.0 | |
| bottom_y0, bottom_y1 = 375.0, 590.0 | |
| pieces = [ | |
| f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" ' | |
| 'role="img" aria-labelledby="fig-title fig-desc">', | |
| '<title id="fig-title">Paired Lloyd and Hartigan synthetic GMM results</title>', | |
| '<desc id="fig-desc">Mean NMI and initial fixed-point rate versus dimension, split by noise variance.</desc>', | |
| """<style> | |
| text { font-family: ui-sans-serif, system-ui, sans-serif; fill: #172033; } | |
| .title { font-size: 18px; font-weight: 700; } | |
| .subtitle { font-size: 12px; fill: #4b5563; } | |
| .panel-title { font-size: 13px; font-weight: 700; } | |
| .tick { font-size: 10px; fill: #596579; } | |
| .axis { stroke: #64748b; stroke-width: 1; } | |
| .grid { stroke: #dbe2ea; stroke-width: 1; } | |
| .lloyd { fill: none; stroke: #d55e00; stroke-width: 2.4; } | |
| .hartigan { fill: none; stroke: #0072b2; stroke-width: 2.4; } | |
| .init { fill: none; stroke: #6b7280; stroke-width: 1.8; stroke-dasharray: 5 4; } | |
| .lloyd-dot { fill: #d55e00; } | |
| .hartigan-dot { fill: #0072b2; } | |
| .init-dot { fill: #6b7280; } | |
| .error { stroke-width: 1.1; opacity: .65; } | |
| </style>""", | |
| f'<text x="{width / 2:.1f}" y="25" text-anchor="middle" class="title">Section 4 scaled GMM reproduction</text>', | |
| f'<text x="{width / 2:.1f}" y="45" text-anchor="middle" class="subtitle">paired balanced partitions; curves show trial means (NMI error bars: ±1 SE)</text>', | |
| '<line x1="82" y1="61" x2="108" y2="61" class="lloyd"/><text x="113" y="65" class="tick">Lloyd</text>', | |
| '<line x1="165" y1="61" x2="191" y2="61" class="hartigan"/><text x="196" y="65" class="tick">Hartigan</text>', | |
| '<line x1="263" y1="61" x2="289" y2="61" class="init"/><text x="294" y="65" class="tick">initial NMI</text>', | |
| '<text x="16" y="195" transform="rotate(-90 16 195)" class="panel-title">Normalized mutual information</text>', | |
| '<text x="16" y="500" transform="rotate(-90 16 500)" class="panel-title">Initial partition fixed rate</text>', | |
| ] | |
| for column, sigma2 in enumerate(noises): | |
| rows = sorted( | |
| (row for row in summary if float(row["sigma2"]) == sigma2), | |
| key=lambda row: int(row["dimension"]), | |
| ) | |
| dimensions = [int(row["dimension"]) for row in rows] | |
| log_min = math.log10(min(dimensions)) | |
| log_max = math.log10(max(dimensions)) | |
| if log_max == log_min: | |
| log_max += 1.0 | |
| x0 = left + column * panel_width + 24 | |
| x1 = left + (column + 1) * panel_width - 20 | |
| def x_coord(dimension: int) -> float: | |
| return x0 + (math.log10(dimension) - log_min) / (log_max - log_min) * (x1 - x0) | |
| def y_coord(value: float, y0: float, y1: float) -> float: | |
| return y1 - min(1.0, max(0.0, value)) * (y1 - y0) | |
| regime = "high noise" if sigma2 > int(rows[0]["n"]) else "control" | |
| pieces.append( | |
| f'<text x="{(x0 + x1) / 2:.1f}" y="79" text-anchor="middle" class="panel-title">σ²={sigma2:g} · {regime}</text>' | |
| ) | |
| for y0, y1 in ((top_y0, top_y1), (bottom_y0, bottom_y1)): | |
| pieces.append(f'<line x1="{x0}" y1="{y1}" x2="{x1}" y2="{y1}" class="axis"/>') | |
| pieces.append(f'<line x1="{x0}" y1="{y0}" x2="{x0}" y2="{y1}" class="axis"/>') | |
| for tick in (0.0, 0.5, 1.0): | |
| y = y_coord(tick, y0, y1) | |
| pieces.append(f'<line x1="{x0}" y1="{y:.2f}" x2="{x1}" y2="{y:.2f}" class="grid"/>') | |
| if column == 0: | |
| pieces.append(f'<text x="{x0 - 7}" y="{y + 3:.2f}" text-anchor="end" class="tick">{tick:g}</text>') | |
| for dimension in dimensions: | |
| x = x_coord(dimension) | |
| pieces.append(f'<line x1="{x:.2f}" y1="{y1}" x2="{x:.2f}" y2="{y1 + 4}" class="axis"/>') | |
| pieces.append(f'<text x="{x:.2f}" y="{y1 + 17}" text-anchor="middle" class="tick">{dimension:g}</text>') | |
| pieces.append(f'<text x="{(x0 + x1) / 2:.1f}" y="625" text-anchor="middle" class="subtitle">dimension d (log scale)</text>') | |
| top_series = ( | |
| ("init_nmi_mean", "init", "init-dot", None), | |
| ("lloyd_nmi_mean", "lloyd", "lloyd-dot", "lloyd_nmi_se"), | |
| ("hartigan_nmi_mean", "hartigan", "hartigan-dot", "hartigan_nmi_se"), | |
| ) | |
| for field, line_class, dot_class, se_field in top_series: | |
| points = [(x_coord(int(row["dimension"])), y_coord(float(row[field]), top_y0, top_y1)) for row in rows] | |
| pieces.append(_polyline(points, line_class)) | |
| for (x, y), row in zip(points, rows): | |
| if se_field is not None: | |
| upper = y_coord(float(row[field]) + float(row[se_field]), top_y0, top_y1) | |
| lower = y_coord(float(row[field]) - float(row[se_field]), top_y0, top_y1) | |
| pieces.append(f'<line x1="{x:.2f}" y1="{upper:.2f}" x2="{x:.2f}" y2="{lower:.2f}" class="error {line_class}"/>') | |
| pieces.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="3" class="{dot_class}"/>') | |
| bottom_series = ( | |
| ("init_lloyd_fixed_rate", "lloyd", "lloyd-dot"), | |
| ("init_hartigan_fixed_rate", "hartigan", "hartigan-dot"), | |
| ) | |
| for field, line_class, dot_class in bottom_series: | |
| points = [(x_coord(int(row["dimension"])), y_coord(float(row[field]), bottom_y0, bottom_y1)) for row in rows] | |
| pieces.append(_polyline(points, line_class)) | |
| for x, y in points: | |
| pieces.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="3" class="{dot_class}"/>') | |
| pieces.append("</svg>") | |
| return "\n".join(pieces) | |
| def _make_html( | |
| *, | |
| svg: str, | |
| summary: Sequence[dict[str, Any]], | |
| config: dict[str, Any], | |
| metadata: dict[str, Any], | |
| checks: dict[str, Any], | |
| ) -> str: | |
| table_rows = [] | |
| for row in summary: | |
| table_rows.append( | |
| "<tr>" | |
| f'<td>{int(row["dimension"])}</td>' | |
| f'<td>{float(row["sigma2"]):g}</td>' | |
| f'<td>{"yes" if row["high_noise_sigma2_gt_n"] else "no"}</td>' | |
| f'<td>{float(row["lloyd_nmi_mean"]):.3f}</td>' | |
| f'<td>{float(row["hartigan_nmi_mean"]):.3f}</td>' | |
| f'<td>{float(row["init_lloyd_fixed_rate"]):.3f}</td>' | |
| f'<td>{float(row["init_hartigan_fixed_rate"]):.3f}</td>' | |
| f'<td>{float(row["hartigan_minus_lloyd_nmi_mean"]):+.3f}</td>' | |
| "</tr>" | |
| ) | |
| config_text = html.escape(json.dumps(config, indent=2, sort_keys=True)) | |
| metadata_text = html.escape(json.dumps(metadata, indent=2, sort_keys=True)) | |
| checked = int(checks["hartigan_fixed_points_checked"]) | |
| return f"""<!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Section 4 k-means reproduction</title> | |
| <style> | |
| body {{ margin: 0; color: #172033; background: #f5f7fb; font: 15px/1.5 ui-sans-serif, system-ui, sans-serif; }} | |
| main {{ max-width: 1250px; margin: 0 auto; padding: 28px; }} | |
| .card {{ background: white; border: 1px solid #dce3ec; border-radius: 12px; padding: 20px; margin: 16px 0; box-shadow: 0 2px 8px #1720330c; }} | |
| h1, h2 {{ line-height: 1.2; }} | |
| .lede {{ color: #4b5563; max-width: 82ch; }} | |
| .figure {{ overflow-x: auto; }} | |
| .figure svg {{ min-width: 760px; width: 100%; height: auto; }} | |
| table {{ width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }} | |
| th, td {{ padding: 7px 9px; border-bottom: 1px solid #e4e9f0; text-align: right; }} | |
| th {{ background: #f8fafc; position: sticky; top: 0; }} | |
| th:first-child, td:first-child {{ text-align: left; }} | |
| pre {{ overflow: auto; padding: 14px; background: #101827; color: #e5edf8; border-radius: 8px; font-size: 12px; }} | |
| .ok {{ color: #087f5b; font-weight: 650; }} | |
| </style> | |
| </head> | |
| <body><main> | |
| <h1>Section 4: synthetic GMM divergence</h1> | |
| <p class="lede">A dependency-light, paired reproduction of Lloyd versus Hartigan. For every trial, centers are sampled from N(0, τ²I), balanced observations receive Gaussian noise N(0, σ²I), and the two algorithms start from the exact same balanced random label partition. Lloyd updates all labels simultaneously; Hartigan scans samples in fixed order and immediately applies each improving relocation.</p> | |
| <section class="card figure">{svg}</section> | |
| <section class="card"> | |
| <h2>Aggregate results</h2> | |
| <div style="overflow:auto"><table> | |
| <thead><tr><th>d</th><th>σ²</th><th>σ²>n</th><th>Lloyd NMI</th><th>Hartigan NMI</th><th>Lloyd init fixed</th><th>Hartigan init fixed</th><th>NMI gap</th></tr></thead> | |
| <tbody>{''.join(table_rows)}</tbody> | |
| </table></div> | |
| </section> | |
| <section class="card"> | |
| <h2>Invariant check</h2> | |
| <p class="ok">Passed: all {checked} converged Hartigan endpoints checked here are also fixed under a simultaneous Lloyd reassignment.</p> | |
| <p>NMI uses arithmetic-mean entropy normalization and is implemented directly from the contingency table; no scikit-learn or plotting library is used.</p> | |
| </section> | |
| <section class="card"><h2>Configuration</h2><pre>{config_text}</pre></section> | |
| <section class="card"><h2>Backend metadata</h2><pre>{metadata_text}</pre></section> | |
| </main></body></html>""" | |
| def _run(args: argparse.Namespace) -> tuple[list[dict[str, Any]], dict[str, Any]]: | |
| profile = PROFILES[args.profile] | |
| dimensions = tuple(args.dimensions or profile["dimensions"]) | |
| noise_variances = tuple(args.noise_variances or profile["noise_variances"]) | |
| trials = int(args.trials if args.trials is not None else profile["trials"]) | |
| if args.k < 2: | |
| raise ValueError("--k must be at least 2") | |
| if args.n <= args.k or args.n % args.k != 0: | |
| raise ValueError("--n must be greater than and divisible by --k") | |
| if trials <= 0 or args.max_sweeps <= 0: | |
| raise ValueError("--trials and --max-sweeps must be positive") | |
| if args.tau2 <= 0 or not math.isfinite(args.tau2): | |
| raise ValueError("--tau2 must be finite and positive") | |
| if args.batch_size < 0: | |
| raise ValueError("--batch-size cannot be negative") | |
| np_dtype = np.dtype(args.dtype) | |
| torch = None | |
| device_string = "cpu" | |
| backend_version = np.__version__ | |
| device_detail = platform.processor() or platform.machine() | |
| if args.backend == "torch": | |
| try: | |
| import torch as imported_torch | |
| except ImportError as exc: | |
| raise RuntimeError( | |
| "--backend torch requires PyTorch; use --backend numpy locally" | |
| ) from exc | |
| torch = imported_torch | |
| torch.use_deterministic_algorithms(True) | |
| if hasattr(torch.backends, "cudnn"): | |
| torch.backends.cudnn.benchmark = False | |
| torch.backends.cudnn.deterministic = True | |
| if args.device == "auto": | |
| selected_device = "cuda" if torch.cuda.is_available() else "cpu" | |
| else: | |
| selected_device = args.device | |
| if selected_device.startswith("cuda") and not torch.cuda.is_available(): | |
| raise RuntimeError("CUDA was requested but torch.cuda.is_available() is false") | |
| device = torch.device(selected_device) | |
| device_string = str(device) | |
| backend_version = str(torch.__version__) | |
| if device.type == "cuda": | |
| device_detail = torch.cuda.get_device_name(device) | |
| else: | |
| device_detail = platform.processor() or platform.machine() | |
| else: | |
| device = None | |
| if args.device not in ("auto", "cpu"): | |
| raise ValueError("NumPy backend supports only --device auto or cpu") | |
| batch_size = args.batch_size or (10 if args.backend == "torch" else 4) | |
| metadata = { | |
| "backend": args.backend, | |
| "backend_version": backend_version, | |
| "device": device_string, | |
| "device_detail": device_detail, | |
| "dtype": args.dtype, | |
| "numpy_version": np.__version__, | |
| "python_version": platform.python_version(), | |
| "platform": platform.platform(), | |
| "deterministic_algorithms": True, | |
| "trial_rng": "numpy.random.Generator(PCG64), one BLAKE2b-derived seed per trial", | |
| "batch_size": batch_size, | |
| } | |
| raw_rows: list[dict[str, Any]] = [] | |
| for sigma2 in noise_variances: | |
| for dimension in dimensions: | |
| setting_rows: list[dict[str, Any]] = [] | |
| for batch_start in range(0, trials, batch_size): | |
| trial_indices = list(range(batch_start, min(trials, batch_start + batch_size))) | |
| data_np, true_np, initial_np, trial_seeds = _make_batch( | |
| trial_indices, | |
| master_seed=args.seed, | |
| k=args.k, | |
| n=args.n, | |
| dimension=dimension, | |
| tau2=args.tau2, | |
| sigma2=sigma2, | |
| dtype=np_dtype, | |
| ) | |
| if args.backend == "numpy": | |
| lloyd = _lloyd_numpy(data_np, initial_np, args.k, args.max_sweeps) | |
| hartigan = _hartigan_numpy(data_np, initial_np, args.k, args.max_sweeps) | |
| true_sse = _loss_numpy(data_np, true_np, args.k) | |
| init_sse = _loss_numpy(data_np, initial_np, args.k) | |
| else: | |
| assert torch is not None and device is not None | |
| data_tensor = torch.as_tensor(data_np, device=device) | |
| true_tensor = torch.as_tensor(true_np, device=device) | |
| initial_tensor = torch.as_tensor(initial_np, device=device) | |
| with torch.no_grad(): | |
| lloyd_t = _lloyd_torch( | |
| data_tensor, initial_tensor, args.k, args.max_sweeps, torch | |
| ) | |
| hartigan_t = _hartigan_torch( | |
| data_tensor, initial_tensor, args.k, args.max_sweeps, torch | |
| ) | |
| true_sse_t = _loss_torch(data_tensor, true_tensor, args.k, torch) | |
| init_sse_t = _loss_torch(data_tensor, initial_tensor, args.k, torch) | |
| lloyd = _torch_result_to_numpy(lloyd_t) | |
| hartigan = _torch_result_to_numpy(hartigan_t) | |
| true_sse = true_sse_t.detach().cpu().numpy() | |
| init_sse = init_sse_t.detach().cpu().numpy() | |
| converged_hartigan = np.asarray(hartigan["converged"], dtype=bool) | |
| hartigan_lloyd_fixed = np.asarray(hartigan["lloyd_fixed"], dtype=bool) | |
| if not np.all(hartigan_lloyd_fixed[converged_hartigan]): | |
| bad = np.flatnonzero( | |
| converged_hartigan & ~hartigan_lloyd_fixed | |
| ).tolist() | |
| raise AssertionError( | |
| "Hartigan fixed point was not Lloyd-fixed for local batch rows " | |
| f"{bad} at d={dimension}, sigma2={sigma2}" | |
| ) | |
| for local_index, trial in enumerate(trial_indices): | |
| true_labels = true_np[local_index] | |
| initial_labels = initial_np[local_index] | |
| lloyd_labels = np.asarray(lloyd["labels"][local_index]) | |
| hartigan_labels = np.asarray(hartigan["labels"][local_index]) | |
| init_nmi = normalized_mutual_information(true_labels, initial_labels) | |
| lloyd_nmi = normalized_mutual_information(true_labels, lloyd_labels) | |
| hartigan_nmi = normalized_mutual_information(true_labels, hartigan_labels) | |
| denominator = max(float(true_sse[local_index]), np.finfo(float).tiny) | |
| row = { | |
| "profile": args.profile, | |
| "backend": args.backend, | |
| "device": device_string, | |
| "dtype": args.dtype, | |
| "seed": args.seed, | |
| "trial_seed": trial_seeds[local_index], | |
| "trial": trial, | |
| "k": args.k, | |
| "n": args.n, | |
| "dimension": dimension, | |
| "tau2": args.tau2, | |
| "sigma2": sigma2, | |
| "high_noise_sigma2_gt_n": int(sigma2 > args.n), | |
| "init_nmi": init_nmi, | |
| "lloyd_nmi": lloyd_nmi, | |
| "hartigan_nmi": hartigan_nmi, | |
| "hartigan_minus_lloyd_nmi": hartigan_nmi - lloyd_nmi, | |
| "init_sse": float(init_sse[local_index]), | |
| "true_partition_sse": float(true_sse[local_index]), | |
| "lloyd_sse": float(lloyd["sse"][local_index]), | |
| "hartigan_sse": float(hartigan["sse"][local_index]), | |
| "lloyd_sse_over_true": float(lloyd["sse"][local_index]) / denominator, | |
| "hartigan_sse_over_true": float(hartigan["sse"][local_index]) / denominator, | |
| "init_lloyd_fixed": int(bool(lloyd["initial_fixed"][local_index])), | |
| "init_hartigan_fixed": int(bool(hartigan["initial_fixed"][local_index])), | |
| "lloyd_unchanged_from_init": int(bool(lloyd["unchanged"][local_index])), | |
| "hartigan_unchanged_from_init": int(bool(hartigan["unchanged"][local_index])), | |
| "lloyd_changed_fraction": float(lloyd["changed_fraction"][local_index]), | |
| "hartigan_changed_fraction": float(hartigan["changed_fraction"][local_index]), | |
| "lloyd_iterations": int(lloyd["iterations"][local_index]), | |
| "hartigan_sweeps": int(hartigan["iterations"][local_index]), | |
| "lloyd_converged": int(bool(lloyd["converged"][local_index])), | |
| "hartigan_converged": int(bool(hartigan["converged"][local_index])), | |
| "hartigan_fixed_is_lloyd_fixed": int( | |
| bool(hartigan_lloyd_fixed[local_index]) | |
| if converged_hartigan[local_index] | |
| else False | |
| ), | |
| } | |
| raw_rows.append(row) | |
| setting_rows.append(row) | |
| print( | |
| f"d={dimension:>5} sigma2={sigma2:>6g} " | |
| f"Lloyd NMI={_mean(setting_rows, 'lloyd_nmi'):.3f} " | |
| f"Hartigan NMI={_mean(setting_rows, 'hartigan_nmi'):.3f} " | |
| f"Lloyd-init-fixed={_mean(setting_rows, 'init_lloyd_fixed'):.2f}", | |
| file=sys.stderr, | |
| flush=True, | |
| ) | |
| return raw_rows, metadata | |
| def _build_checks(raw_rows: Sequence[dict[str, Any]]) -> dict[str, Any]: | |
| converged_hartigan = [row for row in raw_rows if row["hartigan_converged"]] | |
| violations = [ | |
| row for row in converged_hartigan if not row["hartigan_fixed_is_lloyd_fixed"] | |
| ] | |
| return { | |
| "paired_trials": len(raw_rows), | |
| "hartigan_fixed_points_checked": len(converged_hartigan), | |
| "hartigan_fixed_but_not_lloyd_fixed": len(violations), | |
| "hartigan_implies_lloyd_fixed_assertion_passed": len(violations) == 0, | |
| "all_lloyd_runs_converged": all(bool(row["lloyd_converged"]) for row in raw_rows), | |
| "all_hartigan_runs_converged": all(bool(row["hartigan_converged"]) for row in raw_rows), | |
| } | |
| def _high_noise_indicators(summary: Sequence[dict[str, Any]]) -> list[dict[str, Any]]: | |
| indicators = [] | |
| high_noises = sorted( | |
| {float(row["sigma2"]) for row in summary if row["high_noise_sigma2_gt_n"]} | |
| ) | |
| for sigma2 in high_noises: | |
| rows = sorted( | |
| (row for row in summary if float(row["sigma2"]) == sigma2), | |
| key=lambda row: int(row["dimension"]), | |
| ) | |
| if not rows: | |
| continue | |
| first, last = rows[0], rows[-1] | |
| indicators.append( | |
| { | |
| "sigma2": sigma2, | |
| "dimension_low": int(first["dimension"]), | |
| "dimension_high": int(last["dimension"]), | |
| "lloyd_initial_fixed_rate_low_d": float(first["init_lloyd_fixed_rate"]), | |
| "lloyd_initial_fixed_rate_high_d": float(last["init_lloyd_fixed_rate"]), | |
| "hartigan_initial_fixed_rate_high_d": float(last["init_hartigan_fixed_rate"]), | |
| "lloyd_nmi_high_d": float(last["lloyd_nmi_mean"]), | |
| "hartigan_nmi_high_d": float(last["hartigan_nmi_mean"]), | |
| "hartigan_minus_lloyd_nmi_high_d": float( | |
| last["hartigan_minus_lloyd_nmi_mean"] | |
| ), | |
| } | |
| ) | |
| return indicators | |
| def build_parser() -> argparse.ArgumentParser: | |
| parser = argparse.ArgumentParser( | |
| description="Reproduce the paired synthetic-GMM divergence in Section 4." | |
| ) | |
| parser.add_argument("--profile", choices=tuple(PROFILES), default="smoke") | |
| parser.add_argument("--backend", choices=("numpy", "torch"), default="numpy") | |
| parser.add_argument( | |
| "--device", | |
| default="auto", | |
| help="auto, cpu, cuda, or a concrete torch device such as cuda:0", | |
| ) | |
| parser.add_argument("--dtype", choices=("float32", "float64"), default="float32") | |
| parser.add_argument("--output-dir", type=Path, default=Path("artifacts/section4")) | |
| parser.add_argument("--seed", type=int, default=29_589) | |
| parser.add_argument("--k", type=int, default=2) | |
| parser.add_argument("--n", type=int, default=40) | |
| parser.add_argument("--tau2", type=float, default=1.0) | |
| parser.add_argument( | |
| "--dimensions", | |
| type=_parse_int_list, | |
| help="optional comma-separated override of the profile dimensions", | |
| ) | |
| parser.add_argument( | |
| "--noise-variances", | |
| type=_parse_float_list, | |
| help="optional comma-separated override of the profile noise variances", | |
| ) | |
| parser.add_argument("--trials", type=int, help="optional trial-count override") | |
| parser.add_argument( | |
| "--batch-size", | |
| type=int, | |
| default=0, | |
| help="0 chooses 4 for NumPy or 10 for torch; seeds are batch-size invariant", | |
| ) | |
| parser.add_argument("--max-sweeps", type=int, default=100) | |
| return parser | |
| def main(argv: Sequence[str] | None = None) -> int: | |
| args = build_parser().parse_args(argv) | |
| args.output_dir.mkdir(parents=True, exist_ok=True) | |
| raw_rows, metadata = _run(args) | |
| summary = _aggregate(raw_rows) | |
| checks = _build_checks(raw_rows) | |
| profile = PROFILES[args.profile] | |
| config = { | |
| "profile": args.profile, | |
| "backend": args.backend, | |
| "device": metadata["device"], | |
| "dtype": args.dtype, | |
| "seed": args.seed, | |
| "k": args.k, | |
| "n": args.n, | |
| "samples_per_cluster": args.n // args.k, | |
| "tau2": args.tau2, | |
| "dimensions": list(args.dimensions or profile["dimensions"]), | |
| "noise_variances": list(args.noise_variances or profile["noise_variances"]), | |
| "trials_per_setting": args.trials if args.trials is not None else profile["trials"], | |
| "batch_size": metadata["batch_size"], | |
| "max_sweeps": args.max_sweeps, | |
| "initialization": "balanced random permutation of the true-label multiset", | |
| "pairing": "identical data and initial labels for Lloyd and Hartigan", | |
| "lloyd_update": "simultaneous exact label-to-mean reassignment", | |
| "hartigan_update": "fixed sample order, sequential immediate relocations", | |
| "nmi_normalization": "2 * MI / (H(true) + H(predicted))", | |
| "recovery_threshold": "NMI >= 0.9", | |
| } | |
| paths = { | |
| "trials_csv": args.output_dir / "section4_trials.csv", | |
| "summary_csv": args.output_dir / "section4_summary.csv", | |
| "results_json": args.output_dir / "section4_results.json", | |
| "figure_svg": args.output_dir / "section4_divergence.svg", | |
| "report_html": args.output_dir / "section4_report.html", | |
| } | |
| _write_csv(paths["trials_csv"], raw_rows, RAW_FIELDS) | |
| _write_csv(paths["summary_csv"], summary, SUMMARY_FIELDS) | |
| svg = _make_svg(summary) | |
| paths["figure_svg"].write_text(svg, encoding="utf-8") | |
| report = _make_html( | |
| svg=svg, summary=summary, config=config, metadata=metadata, checks=checks | |
| ) | |
| paths["report_html"].write_text(report, encoding="utf-8") | |
| payload = { | |
| "schema_version": 1, | |
| "generated_at_utc": dt.datetime.now(dt.timezone.utc).isoformat(), | |
| "experiment": "ICML 2026 paper 29589, scaled Section 4 synthetic GMM", | |
| "config": config, | |
| "backend_metadata": metadata, | |
| "checks": checks, | |
| "high_noise_indicators": _high_noise_indicators(summary), | |
| "summary": summary, | |
| "trials": raw_rows, | |
| "files": {key: path.name for key, path in paths.items()}, | |
| } | |
| paths["results_json"].write_text( | |
| json.dumps(payload, indent=2, sort_keys=True, allow_nan=False) + "\n", | |
| encoding="utf-8", | |
| ) | |
| print( | |
| json.dumps( | |
| { | |
| "status": "ok", | |
| "output_dir": str(args.output_dir.resolve()), | |
| "paired_trials": len(raw_rows), | |
| "files": {key: str(path.resolve()) for key, path in paths.items()}, | |
| }, | |
| indent=2, | |
| ) | |
| ) | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |
Xet Storage Details
- Size:
- 49.2 kB
- Xet hash:
- f76859d5a5cf9f6efa78587100217ebae28a88a3d5a913faa61aa46a1ac1cb0f
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.