Last active
July 7, 2026 12:30
-
-
Save ernanhughes/6d9ed946adbee453a9ea843947655199 to your computer and use it in GitHub Desktop.
PreferenceFutures probe
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| PreferenceFutures probe, v2. | |
| This script tests the claim: | |
| Preferences can contain incremental information about future outcomes. | |
| For a future target F, history/candidate features H,A, and preference Y: | |
| PFI = Loss(F | H, A) - Loss(F | H, A, Y) | |
| Positive PFI means that adding the observed preference improved held-out | |
| forecasting under the evaluated model, dataset, split, and loss. | |
| Version 2 adds the controls required for the synthetic appendix: | |
| 1. Multiple random seeds. | |
| 2. A configurable synthetic preference-to-future effect. | |
| 3. A true null condition where preference has no future effect. | |
| 4. Paired hierarchical bootstrap confidence intervals: | |
| seeds are resampled, then complete held-out sessions are resampled within | |
| each selected seed. | |
| 5. Detailed and summary CSV outputs. | |
| Tracks | |
| ------ | |
| synthetic | |
| Controlled session-continuation data. Use | |
| --synthetic-preference-effects 0,0.25,0.5,0.75 | |
| to verify that measured PFI is near zero under the null and increases as | |
| preference information is injected. | |
| arena | |
| Loads lmarena-ai/arena-human-preference-140k from Hugging Face, groups rows | |
| by evaluation_session_id, orders them by evaluation_order, and predicts | |
| whether another evaluation follows the current vote. | |
| Examples | |
| -------- | |
| Synthetic null and positive controls across ten seeds: | |
| python preference_futures_probe.py \ | |
| --track synthetic \ | |
| --seeds 1,2,3,4,5,6,7,8,9,10 \ | |
| --synthetic-preference-effects 0,0.75 \ | |
| --bootstrap-samples 2000 \ | |
| --out synthetic_runs.csv \ | |
| --summary-out synthetic_summary.csv | |
| Synthetic effect calibration curve: | |
| python preference_futures_probe.py \ | |
| --track synthetic \ | |
| --seeds 1,2,3,4,5,6,7,8,9,10 \ | |
| --synthetic-preference-effects 0,0.25,0.5,0.75 \ | |
| --bootstrap-samples 2000 \ | |
| --out synthetic_effect_runs.csv \ | |
| --summary-out synthetic_effect_summary.csv | |
| Arena: | |
| HF_TOKEN=... python preference_futures_probe.py \ | |
| --track arena \ | |
| --seeds 1,2,3,4,5 \ | |
| --bootstrap-samples 2000 \ | |
| --out arena_runs.csv \ | |
| --summary-out arena_summary.csv | |
| Dependencies | |
| ------------ | |
| pip install pandas numpy scikit-learn datasets | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import dataclasses | |
| import json | |
| import math | |
| import random | |
| import re | |
| import sys | |
| from typing import Any, Iterable, Sequence | |
| import numpy as np | |
| import pandas as pd | |
| # --------------------------------------------------------------------------- | |
| # Data records | |
| # --------------------------------------------------------------------------- | |
| @dataclasses.dataclass | |
| class ResultRow: | |
| track: str | |
| condition: str | |
| seed: int | |
| target: str | |
| feature_set: str | |
| n_train: int | |
| n_test: int | |
| n_train_groups: int | |
| n_test_groups: int | |
| loss_name: str | |
| loss: float | |
| brier: float | None = None | |
| auc: float | None = None | |
| accuracy: float | None = None | |
| synthetic_preference_effect: float | None = None | |
| synthetic_shared_latent_effect: float | None = None | |
| @dataclasses.dataclass | |
| class SummaryRow: | |
| track: str | |
| condition: str | |
| statistic: str | |
| n_seeds: int | |
| mean: float | |
| seed_std: float | |
| ci_low: float | |
| ci_high: float | |
| positive_seeds: int | |
| confidence_level: float | |
| bootstrap_samples: int | |
| synthetic_preference_effect: float | None = None | |
| synthetic_shared_latent_effect: float | None = None | |
| @dataclasses.dataclass | |
| class EvaluationBundle: | |
| rows: list[ResultRow] | |
| session_loss_stats: pd.DataFrame | |
| # --------------------------------------------------------------------------- | |
| # Utilities | |
| # --------------------------------------------------------------------------- | |
| def seed_everything(seed: int) -> None: | |
| random.seed(seed) | |
| np.random.seed(seed) | |
| def sigmoid_scalar(value: float) -> float: | |
| if value >= 0: | |
| z = math.exp(-value) | |
| return 1.0 / (1.0 + z) | |
| z = math.exp(value) | |
| return z / (1.0 + z) | |
| def parse_int_list(value: str | None, fallback: int) -> list[int]: | |
| if value is None or not value.strip(): | |
| return [fallback] | |
| parsed = [int(part.strip()) for part in value.split(",") if part.strip()] | |
| if not parsed: | |
| raise ValueError("--seeds did not contain any integers.") | |
| return parsed | |
| def parse_float_list(value: str) -> list[float]: | |
| parsed = [float(part.strip()) for part in value.split(",") if part.strip()] | |
| if not parsed: | |
| raise ValueError("The float list did not contain any values.") | |
| return parsed | |
| def safe_jsonish_len(value: Any) -> int: | |
| if value is None: | |
| return 0 | |
| if isinstance(value, float) and math.isnan(value): | |
| return 0 | |
| if isinstance(value, str): | |
| return len(value) | |
| try: | |
| return len(json.dumps(value, ensure_ascii=False)) | |
| except Exception: | |
| return len(str(value)) | |
| def safe_token_count(value: Any) -> int: | |
| if value is None: | |
| return 0 | |
| if not isinstance(value, str): | |
| try: | |
| value = json.dumps(value, ensure_ascii=False) | |
| except Exception: | |
| value = str(value) | |
| return len(re.findall(r"\w+", value)) | |
| def normalise_winner(value: Any) -> str: | |
| if value is None: | |
| return "unknown" | |
| text = str(value).strip().lower() | |
| aliases = { | |
| "model_a": "a", | |
| "model a": "a", | |
| "winner_model_a": "a", | |
| "model_b": "b", | |
| "model b": "b", | |
| "winner_model_b": "b", | |
| "tie": "tie", | |
| "tie (bothbad)": "both_bad", | |
| "both_bad": "both_bad", | |
| "both bad": "both_bad", | |
| } | |
| if text in aliases: | |
| return aliases[text] | |
| if "both" in text and "bad" in text: | |
| return "both_bad" | |
| if text in {"a", "b"}: | |
| return text | |
| return text or "unknown" | |
| def print_header(title: str) -> None: | |
| print("\n" + "=" * 96) | |
| print(title) | |
| print("=" * 96) | |
| def log_loss_components(y_true: np.ndarray, probabilities: np.ndarray) -> np.ndarray: | |
| p = np.clip(probabilities.astype(float), 1e-12, 1.0 - 1e-12) | |
| y = y_true.astype(float) | |
| return -(y * np.log(p) + (1.0 - y) * np.log(1.0 - p)) | |
| def brier_components(y_true: np.ndarray, probabilities: np.ndarray) -> np.ndarray: | |
| return np.square(probabilities.astype(float) - y_true.astype(float)) | |
| # --------------------------------------------------------------------------- | |
| # Model evaluation | |
| # --------------------------------------------------------------------------- | |
| def fit_binary_model( | |
| train_df: pd.DataFrame, | |
| test_df: pd.DataFrame, | |
| feature_columns: list[str], | |
| target_column: str, | |
| seed: int, | |
| ) -> tuple[np.ndarray, dict[str, float]]: | |
| """Train the simple tabular baseline used by the first probe. | |
| Logistic regression is deliberately retained so v2 remains comparable with | |
| the original positive-control run. Stronger text and nonlinear baselines | |
| belong to the public-data experiment rather than this synthetic validation. | |
| """ | |
| from sklearn.compose import ColumnTransformer | |
| from sklearn.impute import SimpleImputer | |
| from sklearn.linear_model import LogisticRegression | |
| from sklearn.metrics import ( | |
| accuracy_score, | |
| brier_score_loss, | |
| log_loss, | |
| roc_auc_score, | |
| ) | |
| from sklearn.pipeline import Pipeline | |
| from sklearn.preprocessing import OneHotEncoder, StandardScaler | |
| X_train = train_df[feature_columns].copy() | |
| y_train = train_df[target_column].astype(int).to_numpy() | |
| X_test = test_df[feature_columns].copy() | |
| y_test = test_df[target_column].astype(int).to_numpy() | |
| if np.unique(y_train).size < 2: | |
| raise ValueError( | |
| f"Training split for target {target_column!r} contains only one class." | |
| ) | |
| numeric_features = [ | |
| column | |
| for column in feature_columns | |
| if pd.api.types.is_numeric_dtype(X_train[column]) | |
| ] | |
| categorical_features = [ | |
| column for column in feature_columns if column not in numeric_features | |
| ] | |
| numeric_pipe = Pipeline( | |
| steps=[ | |
| ("imputer", SimpleImputer(strategy="median")), | |
| ("scaler", StandardScaler()), | |
| ] | |
| ) | |
| categorical_pipe = Pipeline( | |
| steps=[ | |
| ("imputer", SimpleImputer(strategy="most_frequent")), | |
| ("onehot", OneHotEncoder(handle_unknown="ignore")), | |
| ] | |
| ) | |
| transformers: list[tuple[str, Any, list[str]]] = [] | |
| if numeric_features: | |
| transformers.append(("num", numeric_pipe, numeric_features)) | |
| if categorical_features: | |
| transformers.append(("cat", categorical_pipe, categorical_features)) | |
| preprocessor = ColumnTransformer(transformers=transformers, remainder="drop") | |
| model = LogisticRegression( | |
| max_iter=1000, | |
| class_weight="balanced", | |
| random_state=seed, | |
| ) | |
| pipe = Pipeline( | |
| steps=[ | |
| ("preprocessor", preprocessor), | |
| ("model", model), | |
| ] | |
| ) | |
| pipe.fit(X_train, y_train) | |
| probabilities = pipe.predict_proba(X_test)[:, 1] | |
| predictions = (probabilities >= 0.5).astype(int) | |
| metrics: dict[str, float] = { | |
| "log_loss": float(log_loss(y_test, probabilities, labels=[0, 1])), | |
| "brier": float(brier_score_loss(y_test, probabilities)), | |
| "accuracy": float(accuracy_score(y_test, predictions)), | |
| } | |
| try: | |
| metrics["auc"] = float(roc_auc_score(y_test, probabilities)) | |
| except ValueError: | |
| metrics["auc"] = float("nan") | |
| return probabilities, metrics | |
| def shuffle_preference_columns( | |
| frame: pd.DataFrame, | |
| preference_features: list[str], | |
| seed: int, | |
| ) -> pd.DataFrame: | |
| """Shuffle the one-hot preference vector within evaluation-order buckets.""" | |
| shuffled = frame.copy() | |
| if "evaluation_order" in shuffled.columns: | |
| pieces: list[pd.DataFrame] = [] | |
| for bucket_index, (_, part) in enumerate( | |
| shuffled.groupby("evaluation_order", dropna=False) | |
| ): | |
| values = part[preference_features].sample( | |
| frac=1.0, | |
| random_state=seed + bucket_index, | |
| ).to_numpy() | |
| pieces.append( | |
| pd.DataFrame( | |
| values, | |
| columns=preference_features, | |
| index=part.index, | |
| ) | |
| ) | |
| shuffled_values = pd.concat(pieces).sort_index() | |
| shuffled.loc[:, preference_features] = shuffled_values | |
| else: | |
| rng = np.random.default_rng(seed) | |
| indices = rng.permutation(len(shuffled)) | |
| shuffled.loc[:, preference_features] = ( | |
| shuffled[preference_features].to_numpy()[indices] | |
| ) | |
| return shuffled | |
| def build_session_loss_stats( | |
| test_df: pd.DataFrame, | |
| *, | |
| group_column: str, | |
| target_column: str, | |
| seed: int, | |
| condition: str, | |
| predictions: dict[str, np.ndarray], | |
| ) -> pd.DataFrame: | |
| """Build sufficient statistics for paired session-level bootstrapping.""" | |
| y_true = test_df[target_column].astype(int).to_numpy() | |
| row_stats = pd.DataFrame( | |
| { | |
| "group_id": test_df[group_column].astype(str).to_numpy(), | |
| "n_rows": 1, | |
| }, | |
| index=test_df.index, | |
| ) | |
| for model_name, probability in predictions.items(): | |
| row_stats[f"{model_name}_log_sum"] = log_loss_components(y_true, probability) | |
| row_stats[f"{model_name}_brier_sum"] = brier_components(y_true, probability) | |
| grouped = row_stats.groupby("group_id", as_index=False).sum(numeric_only=True) | |
| grouped["seed"] = seed | |
| grouped["condition"] = condition | |
| return grouped | |
| def evaluate_binary_feature_sets( | |
| df: pd.DataFrame, | |
| *, | |
| group_column: str, | |
| target_column: str, | |
| base_features: list[str], | |
| preference_features: list[str], | |
| track: str, | |
| condition: str, | |
| seed: int, | |
| test_fraction: float = 0.2, | |
| synthetic_preference_effect: float | None = None, | |
| synthetic_shared_latent_effect: float | None = None, | |
| ) -> EvaluationBundle: | |
| """Evaluate no-preference, preference-only, full, and shuffled controls.""" | |
| rng = np.random.default_rng(seed) | |
| groups = np.asarray(sorted(df[group_column].dropna().astype(str).unique())) | |
| if groups.size < 2: | |
| raise ValueError("At least two groups are required for a grouped split.") | |
| rng.shuffle(groups) | |
| n_test_groups = max(1, int(round(len(groups) * test_fraction))) | |
| n_test_groups = min(n_test_groups, len(groups) - 1) | |
| test_groups = set(groups[:n_test_groups]) | |
| group_values = df[group_column].astype(str) | |
| train_df = df[~group_values.isin(test_groups)].copy() | |
| test_df = df[group_values.isin(test_groups)].copy() | |
| if train_df.empty or test_df.empty: | |
| raise ValueError("Empty train/test split. Check grouping and dataset size.") | |
| feature_sets = { | |
| "history_candidate_no_preference": base_features, | |
| "preference_only": preference_features, | |
| "history_candidate_plus_preference": base_features + preference_features, | |
| } | |
| rows: list[ResultRow] = [] | |
| predictions: dict[str, np.ndarray] = {} | |
| for name, columns in feature_sets.items(): | |
| probability, metrics = fit_binary_model( | |
| train_df, | |
| test_df, | |
| columns, | |
| target_column, | |
| seed, | |
| ) | |
| predictions[name] = probability | |
| rows.append( | |
| ResultRow( | |
| track=track, | |
| condition=condition, | |
| seed=seed, | |
| target=target_column, | |
| feature_set=name, | |
| n_train=len(train_df), | |
| n_test=len(test_df), | |
| n_train_groups=train_df[group_column].astype(str).nunique(), | |
| n_test_groups=test_df[group_column].astype(str).nunique(), | |
| loss_name="log_loss", | |
| loss=metrics["log_loss"], | |
| brier=metrics["brier"], | |
| auc=metrics["auc"], | |
| accuracy=metrics["accuracy"], | |
| synthetic_preference_effect=synthetic_preference_effect, | |
| synthetic_shared_latent_effect=synthetic_shared_latent_effect, | |
| ) | |
| ) | |
| shuffled_train = shuffle_preference_columns( | |
| train_df, preference_features, seed=seed + 10_000 | |
| ) | |
| shuffled_test = shuffle_preference_columns( | |
| test_df, preference_features, seed=seed + 20_000 | |
| ) | |
| shuffled_probability, shuffled_metrics = fit_binary_model( | |
| shuffled_train, | |
| shuffled_test, | |
| base_features + preference_features, | |
| target_column, | |
| seed, | |
| ) | |
| shuffled_name = "history_candidate_plus_shuffled_preference" | |
| predictions[shuffled_name] = shuffled_probability | |
| rows.append( | |
| ResultRow( | |
| track=track, | |
| condition=condition, | |
| seed=seed, | |
| target=target_column, | |
| feature_set=shuffled_name, | |
| n_train=len(shuffled_train), | |
| n_test=len(shuffled_test), | |
| n_train_groups=shuffled_train[group_column].astype(str).nunique(), | |
| n_test_groups=shuffled_test[group_column].astype(str).nunique(), | |
| loss_name="log_loss", | |
| loss=shuffled_metrics["log_loss"], | |
| brier=shuffled_metrics["brier"], | |
| auc=shuffled_metrics["auc"], | |
| accuracy=shuffled_metrics["accuracy"], | |
| synthetic_preference_effect=synthetic_preference_effect, | |
| synthetic_shared_latent_effect=synthetic_shared_latent_effect, | |
| ) | |
| ) | |
| stats = build_session_loss_stats( | |
| test_df, | |
| group_column=group_column, | |
| target_column=target_column, | |
| seed=seed, | |
| condition=condition, | |
| predictions={ | |
| "no_pref": predictions["history_candidate_no_preference"], | |
| "full": predictions["history_candidate_plus_preference"], | |
| "shuffled": predictions[shuffled_name], | |
| }, | |
| ) | |
| return EvaluationBundle(rows=rows, session_loss_stats=stats) | |
| # --------------------------------------------------------------------------- | |
| # Bootstrap and aggregation | |
| # --------------------------------------------------------------------------- | |
| def statistic_from_session_stats(stats: pd.DataFrame, statistic: str) -> float: | |
| total_rows = float(stats["n_rows"].sum()) | |
| if total_rows <= 0: | |
| return float("nan") | |
| if statistic == "pfi_log_loss": | |
| return float( | |
| ( | |
| stats["no_pref_log_sum"].sum() | |
| - stats["full_log_sum"].sum() | |
| ) | |
| / total_rows | |
| ) | |
| if statistic == "pfi_brier": | |
| return float( | |
| ( | |
| stats["no_pref_brier_sum"].sum() | |
| - stats["full_brier_sum"].sum() | |
| ) | |
| / total_rows | |
| ) | |
| if statistic == "shuffle_gap_log_loss": | |
| return float( | |
| ( | |
| stats["shuffled_log_sum"].sum() | |
| - stats["full_log_sum"].sum() | |
| ) | |
| / total_rows | |
| ) | |
| if statistic == "shuffle_gap_brier": | |
| return float( | |
| ( | |
| stats["shuffled_brier_sum"].sum() | |
| - stats["full_brier_sum"].sum() | |
| ) | |
| / total_rows | |
| ) | |
| raise ValueError(f"Unknown statistic: {statistic}") | |
| def hierarchical_bootstrap_interval( | |
| stats: pd.DataFrame, | |
| *, | |
| statistic: str, | |
| samples: int, | |
| confidence_level: float, | |
| seed: int, | |
| ) -> tuple[float, float]: | |
| """Resample seeds, then complete held-out sessions within each seed. | |
| This implementation operates on NumPy sufficient-statistic arrays rather | |
| than repeatedly concatenating pandas frames, keeping publication-sized | |
| bootstrap runs practical. | |
| """ | |
| if samples <= 0: | |
| return float("nan"), float("nan") | |
| if not 0.0 < confidence_level < 1.0: | |
| raise ValueError("--confidence-level must be between 0 and 1.") | |
| rng = np.random.default_rng(seed) | |
| seed_values = np.asarray(sorted(stats["seed"].unique()), dtype=int) | |
| if seed_values.size == 0: | |
| return float("nan"), float("nan") | |
| columns = [ | |
| "n_rows", | |
| "no_pref_log_sum", | |
| "full_log_sum", | |
| "shuffled_log_sum", | |
| "no_pref_brier_sum", | |
| "full_brier_sum", | |
| "shuffled_brier_sum", | |
| ] | |
| by_seed = { | |
| int(seed_value): stats.loc[ | |
| stats["seed"] == seed_value, columns | |
| ].to_numpy(dtype=float) | |
| for seed_value in seed_values | |
| } | |
| def from_totals(totals: np.ndarray) -> float: | |
| n_rows = totals[0] | |
| if n_rows <= 0: | |
| return float("nan") | |
| if statistic == "pfi_log_loss": | |
| return float((totals[1] - totals[2]) / n_rows) | |
| if statistic == "shuffle_gap_log_loss": | |
| return float((totals[3] - totals[2]) / n_rows) | |
| if statistic == "pfi_brier": | |
| return float((totals[4] - totals[5]) / n_rows) | |
| if statistic == "shuffle_gap_brier": | |
| return float((totals[6] - totals[5]) / n_rows) | |
| raise ValueError(f"Unknown statistic: {statistic}") | |
| draws = np.empty(samples, dtype=float) | |
| for draw_index in range(samples): | |
| sampled_seed_values = rng.choice( | |
| seed_values, size=len(seed_values), replace=True | |
| ) | |
| totals = np.zeros(len(columns), dtype=float) | |
| for sampled_seed in sampled_seed_values: | |
| seed_array = by_seed[int(sampled_seed)] | |
| row_indices = rng.integers(0, len(seed_array), size=len(seed_array)) | |
| totals += seed_array[row_indices].sum(axis=0) | |
| draws[draw_index] = from_totals(totals) | |
| alpha = 1.0 - confidence_level | |
| low = float(np.quantile(draws, alpha / 2.0)) | |
| high = float(np.quantile(draws, 1.0 - alpha / 2.0)) | |
| return low, high | |
| def build_summary_rows( | |
| detailed_rows: list[ResultRow], | |
| session_stats: pd.DataFrame, | |
| *, | |
| track: str, | |
| condition: str, | |
| bootstrap_samples: int, | |
| confidence_level: float, | |
| bootstrap_seed: int, | |
| synthetic_preference_effect: float | None, | |
| synthetic_shared_latent_effect: float | None, | |
| ) -> list[SummaryRow]: | |
| result_df = pd.DataFrame([dataclasses.asdict(row) for row in detailed_rows]) | |
| seed_values = sorted(result_df["seed"].unique()) | |
| per_seed: dict[str, list[float]] = { | |
| "pfi_log_loss": [], | |
| "pfi_brier": [], | |
| "shuffle_gap_log_loss": [], | |
| "shuffle_gap_brier": [], | |
| } | |
| for seed_value in seed_values: | |
| subset = result_df[result_df["seed"] == seed_value] | |
| indexed = subset.set_index("feature_set") | |
| no_pref = indexed.loc["history_candidate_no_preference"] | |
| full = indexed.loc["history_candidate_plus_preference"] | |
| shuffled = indexed.loc["history_candidate_plus_shuffled_preference"] | |
| per_seed["pfi_log_loss"].append(float(no_pref["loss"] - full["loss"])) | |
| per_seed["pfi_brier"].append(float(no_pref["brier"] - full["brier"])) | |
| per_seed["shuffle_gap_log_loss"].append( | |
| float(shuffled["loss"] - full["loss"]) | |
| ) | |
| per_seed["shuffle_gap_brier"].append( | |
| float(shuffled["brier"] - full["brier"]) | |
| ) | |
| rows: list[SummaryRow] = [] | |
| for statistic, values_list in per_seed.items(): | |
| values = np.asarray(values_list, dtype=float) | |
| low, high = hierarchical_bootstrap_interval( | |
| session_stats, | |
| statistic=statistic, | |
| samples=bootstrap_samples, | |
| confidence_level=confidence_level, | |
| seed=bootstrap_seed + sum(ord(ch) for ch in statistic), | |
| ) | |
| rows.append( | |
| SummaryRow( | |
| track=track, | |
| condition=condition, | |
| statistic=statistic, | |
| n_seeds=len(values), | |
| mean=float(np.mean(values)), | |
| seed_std=float(np.std(values, ddof=1)) if len(values) > 1 else 0.0, | |
| ci_low=low, | |
| ci_high=high, | |
| positive_seeds=int(np.sum(values > 0)), | |
| confidence_level=confidence_level, | |
| bootstrap_samples=bootstrap_samples, | |
| synthetic_preference_effect=synthetic_preference_effect, | |
| synthetic_shared_latent_effect=synthetic_shared_latent_effect, | |
| ) | |
| ) | |
| return rows | |
| def print_condition_results( | |
| rows: list[ResultRow], | |
| summary_rows: list[SummaryRow], | |
| ) -> None: | |
| result_df = pd.DataFrame([dataclasses.asdict(row) for row in rows]) | |
| metric_columns = ["loss", "brier", "auc", "accuracy"] | |
| aggregate = ( | |
| result_df.groupby("feature_set")[metric_columns] | |
| .agg(["mean", "std"]) | |
| .sort_index() | |
| ) | |
| print("\nFeature-set metrics across seeds") | |
| print(aggregate.to_string(float_format=lambda value: f"{value:.6f}")) | |
| summary_df = pd.DataFrame( | |
| [dataclasses.asdict(row) for row in summary_rows] | |
| ) | |
| display_columns = [ | |
| "statistic", | |
| "n_seeds", | |
| "mean", | |
| "seed_std", | |
| "ci_low", | |
| "ci_high", | |
| "positive_seeds", | |
| ] | |
| print("\nPFI and shuffled-control summary") | |
| print( | |
| summary_df[display_columns].to_string( | |
| index=False, | |
| float_format=lambda value: f"{value:.6f}", | |
| ) | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Synthetic track | |
| # --------------------------------------------------------------------------- | |
| def make_synthetic_dataset( | |
| n_sessions: int, | |
| rows_per_session: int, | |
| seed: int, | |
| preference_effect: float, | |
| shared_latent_effect: float, | |
| ) -> pd.DataFrame: | |
| """Generate controlled future-bearing preference trajectories. | |
| Preference generation: | |
| Y depends on visible candidate features and a latent session regime. | |
| Future generation: | |
| F depends on visible context, independent patience, and | |
| preference_effect * Y. | |
| By default shared_latent_effect is zero. Therefore, when | |
| preference_effect is also zero, Y is conditionally independent of F given | |
| the visible base features: this is the strict null condition. | |
| Setting shared_latent_effect above zero creates an optional observational | |
| confounding experiment in which the preference reveals additional | |
| information about a latent regime that also affects the future. | |
| """ | |
| rng = np.random.default_rng(seed) | |
| records: list[dict[str, Any]] = [] | |
| for session_id in range(n_sessions): | |
| latent_regime = float(rng.normal()) | |
| patience = float(rng.normal()) | |
| session_topic = str( | |
| rng.choice(["code", "writing", "reasoning", "qa"]) | |
| ) | |
| for order in range(rows_per_session): | |
| difficulty = float(rng.normal() + 0.25 * order) | |
| response_gap = float(rng.normal() + 0.45 * latent_regime) | |
| candidate_length_delta = float(rng.normal() + 0.2 * difficulty) | |
| history_length = max( | |
| 1, int(100 + 20 * order + rng.normal(0, 15)) | |
| ) | |
| preference_logit = ( | |
| 0.8 * response_gap | |
| - 0.25 * candidate_length_delta | |
| + 0.65 * latent_regime | |
| + float(rng.normal(0, 0.75)) | |
| ) | |
| preference_a = int( | |
| rng.random() < sigmoid_scalar(preference_logit) | |
| ) | |
| future_logit = ( | |
| -0.25 * order | |
| + 0.45 * difficulty | |
| + 0.35 * patience | |
| + preference_effect * preference_a | |
| + shared_latent_effect * latent_regime | |
| + float(rng.normal(0, 0.8)) | |
| ) | |
| continues = int(rng.random() < sigmoid_scalar(future_logit)) | |
| records.append( | |
| { | |
| "evaluation_session_id": f"s{session_id}", | |
| "evaluation_order": order, | |
| "topic": session_topic, | |
| "history_len": history_length, | |
| "candidate_a_len": ( | |
| 120 + 20 * response_gap + rng.normal(0, 10) | |
| ), | |
| "candidate_b_len": ( | |
| 120 - 20 * response_gap + rng.normal(0, 10) | |
| ), | |
| "candidate_len_delta": candidate_length_delta, | |
| "response_gap_proxy": response_gap, | |
| "difficulty_proxy": difficulty, | |
| "winner_norm": "a" if preference_a else "b", | |
| "pref_a": preference_a, | |
| "pref_b": 1 - preference_a, | |
| "pref_tie": 0, | |
| "pref_both_bad": 0, | |
| "session_continues_after_vote": continues, | |
| } | |
| ) | |
| if not continues: | |
| break | |
| return pd.DataFrame(records) | |
| def synthetic_features() -> tuple[list[str], list[str]]: | |
| base_features = [ | |
| "evaluation_order", | |
| "topic", | |
| "history_len", | |
| "candidate_a_len", | |
| "candidate_b_len", | |
| "candidate_len_delta", | |
| "response_gap_proxy", | |
| "difficulty_proxy", | |
| ] | |
| preference_features = ["pref_a", "pref_b", "pref_tie", "pref_both_bad"] | |
| return base_features, preference_features | |
| def run_synthetic_matrix( | |
| args: argparse.Namespace, | |
| seeds: Sequence[int], | |
| ) -> tuple[list[ResultRow], list[SummaryRow]]: | |
| effects = parse_float_list(args.synthetic_preference_effects) | |
| base_features, preference_features = synthetic_features() | |
| all_rows: list[ResultRow] = [] | |
| all_summaries: list[SummaryRow] = [] | |
| for effect_index, effect in enumerate(effects): | |
| condition = ( | |
| f"synthetic_effect_{effect:g}" | |
| f"_shared_latent_{args.synthetic_shared_latent_effect:g}" | |
| ) | |
| condition_rows: list[ResultRow] = [] | |
| condition_stats: list[pd.DataFrame] = [] | |
| print_header( | |
| f"Synthetic condition: preference effect={effect:g}, " | |
| f"shared latent effect={args.synthetic_shared_latent_effect:g}" | |
| ) | |
| for seed in seeds: | |
| df = make_synthetic_dataset( | |
| n_sessions=args.synthetic_sessions, | |
| rows_per_session=args.synthetic_max_rounds, | |
| seed=seed, | |
| preference_effect=effect, | |
| shared_latent_effect=args.synthetic_shared_latent_effect, | |
| ) | |
| bundle = evaluate_binary_feature_sets( | |
| df, | |
| group_column="evaluation_session_id", | |
| target_column="session_continues_after_vote", | |
| base_features=base_features, | |
| preference_features=preference_features, | |
| track="synthetic", | |
| condition=condition, | |
| seed=seed, | |
| test_fraction=args.test_fraction, | |
| synthetic_preference_effect=effect, | |
| synthetic_shared_latent_effect=args.synthetic_shared_latent_effect, | |
| ) | |
| condition_rows.extend(bundle.rows) | |
| condition_stats.append(bundle.session_loss_stats) | |
| target_rate = float( | |
| df["session_continues_after_vote"].mean() | |
| ) | |
| print( | |
| f"seed={seed:>4} rows={len(df):>6,} " | |
| f"sessions={df['evaluation_session_id'].nunique():>5,} " | |
| f"continuation_rate={target_rate:.4f}" | |
| ) | |
| combined_stats = pd.concat(condition_stats, ignore_index=True) | |
| summaries = build_summary_rows( | |
| condition_rows, | |
| combined_stats, | |
| track="synthetic", | |
| condition=condition, | |
| bootstrap_samples=args.bootstrap_samples, | |
| confidence_level=args.confidence_level, | |
| bootstrap_seed=args.bootstrap_seed + effect_index * 1000, | |
| synthetic_preference_effect=effect, | |
| synthetic_shared_latent_effect=args.synthetic_shared_latent_effect, | |
| ) | |
| print_condition_results(condition_rows, summaries) | |
| all_rows.extend(condition_rows) | |
| all_summaries.extend(summaries) | |
| return all_rows, all_summaries | |
| # --------------------------------------------------------------------------- | |
| # Arena track | |
| # --------------------------------------------------------------------------- | |
| def load_arena_dataframe(limit_rows: int | None) -> pd.DataFrame: | |
| try: | |
| from datasets import load_dataset | |
| except ImportError as exc: | |
| raise SystemExit( | |
| "Missing dependency: datasets. Install with `pip install datasets`." | |
| ) from exc | |
| repo_id = "lmarena-ai/arena-human-preference-140k" | |
| dataset = load_dataset(repo_id, split="train", token=True) | |
| if limit_rows is not None and limit_rows > 0: | |
| dataset = dataset.select(range(min(limit_rows, len(dataset)))) | |
| return dataset.to_pandas() | |
| def pick_first_existing( | |
| columns: Iterable[str], candidates: list[str] | |
| ) -> str | None: | |
| available = set(columns) | |
| for candidate in candidates: | |
| if candidate in available: | |
| return candidate | |
| return None | |
| def prepare_arena_future_dataset( | |
| raw: pd.DataFrame, | |
| ) -> tuple[pd.DataFrame, list[str], list[str]]: | |
| required_candidates = { | |
| "evaluation_session_id": ["evaluation_session_id", "session_id"], | |
| "evaluation_order": ["evaluation_order", "order", "turn"], | |
| "winner": ["winner", "vote", "preference"], | |
| } | |
| resolved: dict[str, str] = {} | |
| for logical_name, candidate_names in required_candidates.items(): | |
| found = pick_first_existing(raw.columns, candidate_names) | |
| if found is None: | |
| schema = "\n".join(f" - {column}" for column in raw.columns) | |
| raise ValueError( | |
| f"Could not find required logical field {logical_name!r}. " | |
| f"Available columns:\n{schema}" | |
| ) | |
| resolved[logical_name] = found | |
| df = raw.copy() | |
| df["_session_id"] = df[resolved["evaluation_session_id"]].astype(str) | |
| df["_order"] = pd.to_numeric( | |
| df[resolved["evaluation_order"]], errors="coerce" | |
| ) | |
| df = df.dropna(subset=["_order"]).copy() | |
| df["_order"] = df["_order"].astype(int) | |
| df["winner_norm"] = df[resolved["winner"]].map(normalise_winner) | |
| df = df.sort_values(["_session_id", "_order"]).copy() | |
| group_sizes = df.groupby("_session_id")["_order"].transform("size") | |
| group_rank = df.groupby("_session_id").cumcount() | |
| df["session_continues_after_vote"] = ( | |
| group_rank < group_sizes - 1 | |
| ).astype(int) | |
| conversation_a = pick_first_existing( | |
| df.columns, | |
| ["conversation_a", "messages_a", "response_a", "answer_a"], | |
| ) | |
| conversation_b = pick_first_existing( | |
| df.columns, | |
| ["conversation_b", "messages_b", "response_b", "answer_b"], | |
| ) | |
| full_conversation = pick_first_existing( | |
| df.columns, | |
| ["full_conversation", "conversation", "messages", "prompt"], | |
| ) | |
| model_a = pick_first_existing( | |
| df.columns, ["model_a", "model_a_name"] | |
| ) | |
| model_b = pick_first_existing( | |
| df.columns, ["model_b", "model_b_name"] | |
| ) | |
| category = pick_first_existing( | |
| df.columns, | |
| ["category", "categories", "turn_category", "language"], | |
| ) | |
| df["candidate_a_chars"] = ( | |
| df[conversation_a].map(safe_jsonish_len) if conversation_a else 0 | |
| ) | |
| df["candidate_a_tokens"] = ( | |
| df[conversation_a].map(safe_token_count) if conversation_a else 0 | |
| ) | |
| df["candidate_b_chars"] = ( | |
| df[conversation_b].map(safe_jsonish_len) if conversation_b else 0 | |
| ) | |
| df["candidate_b_tokens"] = ( | |
| df[conversation_b].map(safe_token_count) if conversation_b else 0 | |
| ) | |
| if full_conversation: | |
| df["history_chars"] = df[full_conversation].map(safe_jsonish_len) | |
| df["history_tokens"] = df[full_conversation].map(safe_token_count) | |
| else: | |
| df["history_chars"] = ( | |
| df["candidate_a_chars"] + df["candidate_b_chars"] | |
| ) | |
| df["history_tokens"] = ( | |
| df["candidate_a_tokens"] + df["candidate_b_tokens"] | |
| ) | |
| df["candidate_char_delta"] = ( | |
| df["candidate_a_chars"] - df["candidate_b_chars"] | |
| ) | |
| df["candidate_token_delta"] = ( | |
| df["candidate_a_tokens"] - df["candidate_b_tokens"] | |
| ) | |
| df["model_a_feature"] = ( | |
| df[model_a].astype(str) if model_a else "unknown" | |
| ) | |
| df["model_b_feature"] = ( | |
| df[model_b].astype(str) if model_b else "unknown" | |
| ) | |
| df["category_feature"] = ( | |
| df[category].astype(str) if category else "unknown" | |
| ) | |
| for label in ["a", "b", "tie", "both_bad"]: | |
| df[f"pref_{label}"] = ( | |
| df["winner_norm"] == label | |
| ).astype(int) | |
| prepared = df.rename( | |
| columns={ | |
| "_session_id": "evaluation_session_id", | |
| "_order": "evaluation_order", | |
| } | |
| ) | |
| base_features = [ | |
| "evaluation_order", | |
| "history_chars", | |
| "history_tokens", | |
| "candidate_a_chars", | |
| "candidate_b_chars", | |
| "candidate_a_tokens", | |
| "candidate_b_tokens", | |
| "candidate_char_delta", | |
| "candidate_token_delta", | |
| "model_a_feature", | |
| "model_b_feature", | |
| "category_feature", | |
| ] | |
| preference_features = ["pref_a", "pref_b", "pref_tie", "pref_both_bad"] | |
| return prepared, base_features, preference_features | |
| def run_arena_matrix( | |
| args: argparse.Namespace, | |
| seeds: Sequence[int], | |
| ) -> tuple[list[ResultRow], list[SummaryRow]]: | |
| raw = load_arena_dataframe(args.limit_rows) | |
| df, base_features, preference_features = prepare_arena_future_dataset(raw) | |
| print_header("Arena dataset audit") | |
| session_counts = df["evaluation_session_id"].value_counts() | |
| print(f"Rows: {len(df):,}") | |
| print(f"Sessions: {df['evaluation_session_id'].nunique():,}") | |
| print(session_counts.describe().to_string()) | |
| print("Sessions with 2+ evaluations:", int((session_counts >= 2).sum())) | |
| print("Sessions with 3+ evaluations:", int((session_counts >= 3).sum())) | |
| print("Sessions with 5+ evaluations:", int((session_counts >= 5).sum())) | |
| print("\nWinner distribution:") | |
| print(df["winner_norm"].value_counts(dropna=False).to_string()) | |
| print("\nContinuation target distribution:") | |
| print( | |
| df["session_continues_after_vote"] | |
| .value_counts(normalize=True) | |
| .to_string() | |
| ) | |
| condition = "arena" | |
| condition_rows: list[ResultRow] = [] | |
| condition_stats: list[pd.DataFrame] = [] | |
| for seed in seeds: | |
| bundle = evaluate_binary_feature_sets( | |
| df, | |
| group_column="evaluation_session_id", | |
| target_column="session_continues_after_vote", | |
| base_features=base_features, | |
| preference_features=preference_features, | |
| track="arena", | |
| condition=condition, | |
| seed=seed, | |
| test_fraction=args.test_fraction, | |
| ) | |
| condition_rows.extend(bundle.rows) | |
| condition_stats.append(bundle.session_loss_stats) | |
| combined_stats = pd.concat(condition_stats, ignore_index=True) | |
| summaries = build_summary_rows( | |
| condition_rows, | |
| combined_stats, | |
| track="arena", | |
| condition=condition, | |
| bootstrap_samples=args.bootstrap_samples, | |
| confidence_level=args.confidence_level, | |
| bootstrap_seed=args.bootstrap_seed, | |
| synthetic_preference_effect=None, | |
| synthetic_shared_latent_effect=None, | |
| ) | |
| print_condition_results(condition_rows, summaries) | |
| return condition_rows, summaries | |
| # --------------------------------------------------------------------------- | |
| # CLI | |
| # --------------------------------------------------------------------------- | |
| def parse_args(argv: list[str]) -> argparse.Namespace: | |
| parser = argparse.ArgumentParser( | |
| description=( | |
| "Test whether preferences contain incremental information " | |
| "about future outcomes." | |
| ) | |
| ) | |
| parser.add_argument( | |
| "--track", | |
| choices=["synthetic", "arena"], | |
| default="synthetic", | |
| ) | |
| parser.add_argument( | |
| "--seed", | |
| type=int, | |
| default=7, | |
| help="Fallback single seed when --seeds is omitted.", | |
| ) | |
| parser.add_argument( | |
| "--seeds", | |
| default=None, | |
| help="Comma-separated run seeds, for example 1,2,3,4,5.", | |
| ) | |
| parser.add_argument( | |
| "--test-fraction", | |
| type=float, | |
| default=0.2, | |
| help="Fraction of complete groups/sessions assigned to test.", | |
| ) | |
| parser.add_argument( | |
| "--bootstrap-samples", | |
| type=int, | |
| default=2000, | |
| help="Hierarchical paired-bootstrap draws.", | |
| ) | |
| parser.add_argument( | |
| "--bootstrap-seed", | |
| type=int, | |
| default=1729, | |
| ) | |
| parser.add_argument( | |
| "--confidence-level", | |
| type=float, | |
| default=0.95, | |
| ) | |
| parser.add_argument( | |
| "--limit-rows", | |
| type=int, | |
| default=None, | |
| help="Limit Hugging Face rows for an Arena smoke test.", | |
| ) | |
| parser.add_argument( | |
| "--synthetic-sessions", | |
| type=int, | |
| default=5000, | |
| ) | |
| parser.add_argument( | |
| "--synthetic-max-rounds", | |
| type=int, | |
| default=6, | |
| ) | |
| parser.add_argument( | |
| "--synthetic-preference-effects", | |
| default="0.75", | |
| help=( | |
| "Comma-separated direct preference-to-future coefficients. " | |
| "Use 0 for the strict null." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--synthetic-shared-latent-effect", | |
| type=float, | |
| default=0.0, | |
| help=( | |
| "Optional shared latent effect on the future. Leave at 0 for " | |
| "the strict null experiment." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--out", | |
| default=None, | |
| help="CSV path for per-seed feature-set results.", | |
| ) | |
| parser.add_argument( | |
| "--summary-out", | |
| default=None, | |
| help="CSV path for PFI/bootstrap summaries.", | |
| ) | |
| return parser.parse_args(argv) | |
| def main(argv: list[str]) -> int: | |
| args = parse_args(argv) | |
| if not 0.0 < args.test_fraction < 1.0: | |
| raise SystemExit("--test-fraction must be between 0 and 1.") | |
| if args.bootstrap_samples < 0: | |
| raise SystemExit("--bootstrap-samples must be non-negative.") | |
| seeds = parse_int_list(args.seeds, fallback=args.seed) | |
| for seed in seeds: | |
| seed_everything(seed) | |
| if args.track == "synthetic": | |
| rows, summaries = run_synthetic_matrix(args, seeds) | |
| elif args.track == "arena": | |
| rows, summaries = run_arena_matrix(args, seeds) | |
| else: | |
| raise AssertionError(args.track) | |
| print_header("PreferenceFutures final summary") | |
| summary_df = pd.DataFrame( | |
| [dataclasses.asdict(row) for row in summaries] | |
| ) | |
| print( | |
| summary_df.to_string( | |
| index=False, | |
| float_format=lambda value: f"{value:.6f}", | |
| ) | |
| ) | |
| if args.out: | |
| detailed_df = pd.DataFrame( | |
| [dataclasses.asdict(row) for row in rows] | |
| ) | |
| detailed_df.to_csv(args.out, index=False) | |
| print(f"\nSaved per-seed results to {args.out}") | |
| if args.summary_out: | |
| summary_df.to_csv(args.summary_out, index=False) | |
| print(f"Saved summary results to {args.summary_out}") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main(sys.argv[1:])) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment