Label-Free Bayesian Truth Serum Reward / bts_toy.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import json
  2import math
  3from pathlib import Path
  4import numpy as np
  5
  6EPS = 1e-3
  7
  8
  9def bts_score(p_hat, y, eps=EPS):
 10    return math.log((p_hat + eps) / (y + eps))
 11
 12
 13def prediction_frequency_scaling():
 14    # At fixed y, changing p changes the score by exactly
 15    # log((p1+eps)/(p0+eps)); without epsilon this is log(p1/p0).
 16    y, p0 = 0.20, 0.25
 17    rows = []
 18    for rho in [0.5, 0.8, 1.0, 1.5, 2.0]:
 19        p1 = min(0.95, p0 * rho)
 20        observed = bts_score(p1, y) - bts_score(p0, y)
 21        predicted = math.log((p1 + EPS) / (p0 + EPS))
 22        rows.append(dict(rho=rho, observed=observed, predicted=predicted,
 23                         abs_error=abs(observed - predicted)))
 24    return rows
 25
 26
 27def monotonicity_sweep():
 28    # The derivative with respect to p_hat is 1/(p_hat+eps)>0.
 29    y = 0.35
 30    ps = np.linspace(0.02, 0.98, 25)
 31    rs = np.array([bts_score(float(p), y) for p in ps])
 32    return dict(min_adjacent_delta=float(np.min(np.diff(rs))),
 33                p_at_max=float(ps[np.argmax(rs)]), r_at_min=float(rs[0]),
 34                r_at_max=float(rs[-1]))
 35
 36
 37def variance_scaling(rng):
 38    # For K~Binomial(G,p), delta method predicts
 39    # Var[log((K/G+eps)/(y+eps))] ~= p(1-p)/(G*(p+eps)^2).
 40    p, y, trials = 0.30, 0.20, 50000
 41    rows = []
 42    for G in [8, 16, 32, 64, 128, 256]:
 43        k = rng.binomial(G, p, size=trials)
 44        scores = np.log((k / G + EPS) / (y + EPS))
 45        observed = float(np.var(scores, ddof=1))
 46        predicted = p * (1-p) / (G * (p + EPS)**2)
 47        rows.append(dict(G=G, observed=observed, predicted=predicted,
 48                         observed_times_G=observed*G,
 49                         predicted_times_G=predicted*G,
 50                         ratio=observed/predicted))
 51    return rows
 52
 53
 54def group_reward(answer, predicted, answers, eps=EPS):
 55    counts = np.bincount(answers, minlength=2) / len(answers)
 56    return np.log((counts[answer] + eps) / (predicted + eps))
 57
 58
 59def policy_simulation(rng, rounds=4000, G=16):
 60    # Two answer policies: honest follows latent truth; sycophantic follows
 61    # the user pressure. Each completion predicts the group's answer rate.
 62    # BTS is applied as a group-relative reward; baseline is majority/agreement.
 63    # This is a mechanism simulation, not neural-network training.
 64    results = {}
 65    for method in ["bts", "agreement"]:
 66        for condition in ["neutral", "pressure"]:
 67            correct = flips = 0
 68            reward_var = []
 69            for _ in range(rounds):
 70                truth = int(rng.random() < 0.5)
 71                pressure = truth ^ 1 if condition == "pressure" else truth
 72                # A mixed population: honest samples have 0.78 truth fidelity,
 73                # sycophantic samples have 0.84 pressure fidelity.
 74                honest = rng.random(G) < 0.55
 75                answers = np.where(honest,
 76                                  truth ^ (rng.random(G) > 0.78),
 77                                  pressure ^ (rng.random(G) > 0.84)).astype(int)
 78                # Prediction reports are calibrated to each respondent's rule.
 79                pred = np.where(honest, 0.55 + 0.23 * truth,
 80                                0.55 + 0.29 * pressure).astype(float)
 81                # pred is probability of answer 1; convert for each answer.
 82                ys = np.where(answers == 1, pred, 1-pred)
 83                if method == "bts":
 84                    scores = np.array([group_reward(int(a), float(y), answers)
 85                                       for a, y in zip(answers, ys)])
 86                    reward_var.append(float(np.var(scores)))
 87                    # Reward-weighted soft selection of a policy for the next
 88                    # prompt: BTS favors reports surprising relative to their
 89                    # own prediction, rather than raw consensus.
 90                    w = np.exp(np.clip(scores - scores.max(), -10, 10))
 91                    chosen = int(answers[rng.choice(G, p=w/w.sum())])
 92                else:
 93                    reward = np.bincount(answers, minlength=2) / G
 94                    reward_var.append(float(np.var(reward[answers])))
 95                    chosen = int(np.argmax(np.bincount(answers, minlength=2)))
 96                correct += chosen == truth
 97                flips += condition == "pressure" and chosen != truth
 98            results[f"{method}_{condition}"] = dict(accuracy=correct/rounds,
 99                                                       pressure_flip_rate=flips/rounds,
100                                                       mean_reward_variance=float(np.mean(reward_var)))
101    return results
102
103
104def main():
105    rng = np.random.default_rng(2735)
106    out = {
107        "prediction_1_log_scaling": prediction_frequency_scaling(),
108        "prediction_2_monotonicity": monotonicity_sweep(),
109        "prediction_3_variance_1_over_G": variance_scaling(rng),
110        "policy_simulation": policy_simulation(rng),
111    }
112    Path("results.json").write_text(json.dumps(out, indent=2))
113    print(json.dumps(out, indent=2))
114
115
116if __name__ == "__main__":
117    main()