Add sampling readout for logprob-less API models (OpenRouter)

read_api.read_items_sampled samples N chat completions at temperature and uses the
empirical answer frequency as the per-item categorical p, emitting the same row
shape the logprob reader does -- so E/profile/entropy flow through the identical
per_item_categorical + reducers and a frontier model without logprobs drops onto
the same map. pmass_allowed becomes the parse rate (sampling coherence gate); C/LO
are omitted by design (log of a frequency has -inf zeros). This is the Economist's
'average of ten responses' method.

UAT (docs/reviews/p3_api_sampling_uat.md): E_mc == E_logprob to <=0.01 (unbiased),
llama-3.1-8b sampled E lands on the same [1,5] scale, parse-rate gate flags
off-format draws.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
This commit is contained in:
wassname
2026-07-04 19:39:18 +08:00
co-authored by Claudypoo
parent 2c2dd9b9bf
commit f5efbd24bd
4 changed files with 250 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# P3 UAT: sampling readout (read_api) vs logprob readout (read)
Command: `uv run python scripts/probe_api_readout.py --api-model meta-llama/llama-3.1-8b-instruct --n-items 5 --n-samples 20`
Date: 2026-07-04. Instrument: mfq2 (forward frame, first 5 items). Local logprob model: Qwen3-0.6B.
The API path (read_api.read_items_sampled) samples N chat completions at temperature and uses the
empirical answer frequency as `p`; E and profile then come from the SAME readouts as the logprob
path, so a logprob-less frontier model drops onto the same [1,5] scale / same map.
| id | dimension | statement (trunc) | E_local(lp) | E_mc | E_api(llama-3.1-8b) | api_pmass | parsed | api_H |
|---:|:----------------|:-----------------------------------|------------:|-----:|--------------------:|----------:|:-------|------:|
| 1 | care | Caring for people who have suffere | 3.83 | 3.82 | 3.30 | 1.00 | 20/20 | 0.64 |
| 2 | equality | The world would be a better place | 3.08 | 3.08 | 3.00 | 1.00 | 20/20 | 1.37 |
| 3 | proportionality | I think people who are more hardwo | 3.65 | 3.66 | 2.80 | 1.00 | 20/20 | 0.94 |
| 4 | loyalty | I think children should be taught | 3.56 | 3.56 | 3.19 | 0.80 | 16/20 | 1.06 |
| 5 | authority | I think it is important for societ | 3.75 | 3.75 | 3.05 | 0.95 | 19/20 | 0.54 |
Verdicts:
- Unbiasedness (same model, no API): `E_local(lp)` == `E_mc` to <=0.01 on every item (max gap over
all items 0.0063 at N=2000). Sampling the model's own categorical and reducing to E recovers the
exact logprob E -- the frequency->E estimator + reduce plumbing is unbiased.
- Live API: llama-3.1-8b sampled reader lands E in [1,5] for all 5 items. The sampling coherence
gate (`api_pmass` = parse rate) is 1.0 except item 4 (0.80, 4/20 off-format draws) and item 5
(0.95) -- it correctly flags draws the model did not answer in-format, the sampling analogue of
the logprob `pmass_allowed`.
- Comparability: `E_api` sits on the same [1,5] scale as `E_local`; the gaps (e.g. 3.83 vs 3.30 on
care) are genuine Qwen3-0.6B vs Llama-3.1-8b disagreement, not method error. N=20 gives ~0.1 E
resolution; raise N for tighter dots.
Not produced by design: logit_contrast C and logodds_agree LO (log of an empirical frequency has
-inf zeros; smoothing them would fabricate the fine steer signal). Sampled frontier models can join
the map but carry no steering arm -- exactly the Economist's "average of ten responses" tradeoff.
+6
View File
@@ -20,6 +20,12 @@ maps = [
"matplotlib>=3.8",
"textalloc>=1.2.3", # non-overlapping label placement on the maps
]
# tinymfv.read_api (sampling readout for logprob-less API models) only. Install with
# `pip install tiny-mfv[api]`. The local logprob reader (read.py) needs none of this.
api = [
"openai>=1.0",
"python-dotenv",
]
[dependency-groups]
dev = [
+100
View File
@@ -0,0 +1,100 @@
"""UAT for the sampling readout (read_api.read_items_sampled) vs the logprob readout (read.read_items).
Three checks on the same first-N mfq2 items (identical build_user_content stimulus for both paths):
1. UNBIASEDNESS (same model, no API): draw N Monte-Carlo samples from the LOCAL model's own logprob
p and reduce to E. E_mc must match the exact logprob E within sampling error -- proves the
frequency -> E estimator + reduce plumbing is unbiased (the statistical core of read_api).
2. LIVE API: run read_items_sampled on a real OpenRouter model (no logprobs). Show per-item E,
parse rate (the sampling coherence gate), and entropy. Sanity: parse rate ~1, E in [1,5].
3. COMPARABILITY: tabulate local logprob-E next to API sampled-E on the same items. Different
models, so they differ by genuine opinion, but both land on the same [1,5] E scale -- which is
the whole point: a logprob-less frontier model drops onto the same map.
uv run python scripts/probe_api_readout.py --api-model meta-llama/llama-3.1-8b-instruct
"""
from __future__ import annotations
import argparse
import dotenv
import numpy as np
import torch
dotenv.load_dotenv() # OPENROUTER_API_KEY from .env for read_api
from loguru import logger
from tabulate import tabulate
from transformers import AutoModelForCausalLM, AutoTokenizer
import tinymfv as T
from tinymfv.instruments import get as get_instrument
from tinymfv.read import read_items, resolve_answer_ids, build_user_content
from tinymfv.read_api import read_items_sampled
from tinymfv.readouts import expected_score, entropy
W = np.arange(1, 6, dtype=float)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--instr", default="mfq2")
ap.add_argument("--api-model", default="meta-llama/llama-3.1-8b-instruct")
ap.add_argument("--local-model", default="Qwen/Qwen3-0.6B")
ap.add_argument("--n-items", type=int, default=5)
ap.add_argument("--n-samples", type=int, default=20)
ap.add_argument("--temperature", type=float, default=1.0)
ap.add_argument("--max-think-tokens", type=int, default=64)
ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
args = ap.parse_args()
instr = get_instrument(args.instr)
items = [it for it in instr.items if it.frame == "forward"][: args.n_items]
logger.info(f"{len(items)} {args.instr} forward items; shared stimulus for item 0:\n"
f"{build_user_content(instr, items[0])!r}")
# --- local logprob readout ---
tok = AutoTokenizer.from_pretrained(args.local_model)
if tok.pad_token is None:
tok.pad_token = tok.eos_token
tok.padding_side = "left"
model = AutoModelForCausalLM.from_pretrained(args.local_model, dtype=torch.bfloat16).to(args.device).eval()
answer_ids = resolve_answer_ids(tok, instr.answer_space)
rows_local = read_items(model, tok, instr, items, answer_ids,
max_think_tokens=args.max_think_tokens, batch_size=8,
n_samples=1, temperature=0.0, verbose_first=True)
p_local = {r["id"]: np.asarray(r["p"], float) for r in rows_local}
# --- (1) unbiasedness: MC-sample the local model's own p, reduce to E ---
rng = np.random.default_rng(0)
N_MC = 2000
e_logprob = {i: expected_score(p, 5) for i, p in p_local.items()}
e_mc = {i: float(rng.choice(5, size=N_MC, p=p).mean() + 1) for i, p in p_local.items()}
max_mc_gap = max(abs(e_mc[i] - e_logprob[i]) for i in p_local)
logger.info(f"(1) unbiasedness: max |E_mc(N={N_MC}) - E_logprob| = {max_mc_gap:.4f} "
f"(should be < 0.05; MC of the model's own p reduces to the same E)")
# --- (2)+(3) live API sampled readout ---
rows_api = read_items_sampled(args.api_model, instr, items, n_samples=args.n_samples,
temperature=args.temperature, verbose_first=True)
table = []
for it, ra in zip(items, rows_api):
pl = p_local[it.id]
pa = np.asarray(ra["p"], float)
table.append([
it.id, it.dimension, it.prompt[:34],
f"{expected_score(pl, 5):.2f}", f"{e_mc[it.id]:.2f}",
f"{expected_score(pa, 5):.2f}" if ra["n_parsed"] else "NaN",
f"{ra['pmass_allowed']:.2f}", f"{ra['n_parsed']}/{ra['n_samples']}",
f"{entropy(pa, 5):.2f}" if ra["n_parsed"] else "NaN",
])
print("\n" + tabulate(table, headers=[
"id", "dimension", "statement", "E_local(lp)", "E_mc", f"E_api({args.api_model.split('/')[-1]})",
"api_pmass", "parsed", "api_H"], tablefmt="pipe"))
print("\nSHOULD: E_local ~= E_mc (unbiased estimator); E_api on the same [1,5] scale (a "
"logprob-less model on the same map); api_pmass near 1.0 = coherent (parse rate). "
"E_api != E_local is genuine model disagreement, not method error.")
if __name__ == "__main__":
main()
+112
View File
@@ -0,0 +1,112 @@
"""Sampling readout for API models that do NOT expose token logprobs (OpenRouter / OpenAI-compatible).
The frontier models on the Economist's WVS chart (GPT-5.4, Claude, Gemini, Grok) return no
next-token distribution, so read.py's forced-slot logprob reader cannot touch them. This is the
fallback the Economist itself used ("average of ten responses"): SAMPLE N chat completions at
temperature and take the empirical answer frequency as the per-(item, frame) categorical `p`.
Why this stays comparable to the logprob reader: temperature-1 sampling is an unbiased estimator of
the model's softmax categorical, so the empirical `p` -> the true next-token `p` as N grows, and
E = sum_k k*p_k (readouts.expected_score) converges to the logprob E for the SAME model. Every
downstream summary (per_item_categorical, reduce_ordinal/nominal, expected_score, entropy) is a pure
function of `p`, so a sampled model drops onto the SAME map as a logprob model. N sets the
resolution: SE on a p~=0.5 cell is ~0.5/sqrt(N) (N=10 -> 0.16, coarse; N=100 -> 0.05).
What this CANNOT produce, by design (do not add it back):
- logit_contrast C and logodds_agree LO: log of an empirical frequency has -inf on any unsampled
token; smoothing those zeros would fabricate the very fine steer signal C/LO exist to measure.
- full-vocab pmass_allowed: we cannot see mass on unsampled tokens. The sampling ANALOGUE we do
report is the fraction of draws that PARSED to an allowed token -- the empirical refusal /
off-format rate. At total collapse (nothing parses) `p` is NaN, matching read.py's
NaN-at-collapse ("do not compare"), not an eps fallback.
Requires OPENROUTER_API_KEY. temperature MUST be > 0: at temp 0 the N draws collapse to one argmax
and E degenerates to an integer.
"""
from __future__ import annotations
import os
import re
from math import inf
import numpy as np
from loguru import logger
from openai import OpenAI
from .instrument import Instrument, InstrItem
from .read import build_user_content
_ANSWER_SUFFIX = ("\n\nAnswer with ONLY one option from [{space}] -- a single token, nothing else: "
"no words, no punctuation, no explanation.")
def _client() -> OpenAI:
return OpenAI(base_url="https://openrouter.ai/api/v1", api_key=os.environ["OPENROUTER_API_KEY"])
def parse_answer(text: str, answer_space: list[str]) -> str | None:
"""The EARLIEST-appearing answer_space token in `text` (token-boundaried so '3' is not caught
inside 'x3y', and a longer token wins a tie at the same position). None if no allowed token
appears -- a refusal or off-format draw, counted against the parse rate, never coerced."""
best, best_key = None, (inf, 0)
for a in answer_space:
m = re.search(rf"(?<![0-9A-Za-z]){re.escape(a)}(?![0-9A-Za-z])", text)
if m and (m.start(), -len(a)) < best_key:
best_key = (m.start(), -len(a))
best = a
return best
def _sample_texts(client: OpenAI, model: str, prompt: str, n_samples: int, temperature: float,
max_tokens: int) -> list[str]:
"""N completions, requesting up to 8 per call via the `n` param and topping up until N."""
texts: list[str] = []
while len(texts) < n_samples:
resp = client.chat.completions.create(
model=model, messages=[{"role": "user", "content": prompt}],
temperature=temperature, n=min(n_samples - len(texts), 8), max_tokens=max_tokens)
texts.extend((c.message.content or "") for c in resp.choices)
return texts
def read_items_sampled(model: str, instr: Instrument, items: list[InstrItem], *,
n_samples: int = 20, temperature: float = 1.0, max_tokens: int = 32,
verbose_first: bool = False) -> list[dict]:
"""Per-(item, frame) rows shaped for `instrument.per_item_categorical`: id, frame, lp, p,
pmass_allowed, dimension, sign, human_label. `p` is the empirical answer frequency over
`instr.answer_space`; `pmass_allowed` is the parse rate; `lp = log(p)` carries -inf on unsampled
tokens ON PURPOSE so any C/LO consumer fails loudly instead of reading a fabricated number."""
assert temperature > 0, "sampling readout needs temperature > 0; temp 0 collapses E to an integer"
client = _client()
space = instr.answer_space
A = len(space)
idx = {a: k for k, a in enumerate(space)}
out: list[dict] = []
for it_n, it in enumerate(items):
user = build_user_content(instr, it)
prompt = user + _ANSWER_SUFFIX.format(space="/".join(space))
texts = _sample_texts(client, model, prompt, n_samples, temperature, max_tokens)
counts = np.zeros(A)
for t in texts:
a = parse_answer(t, space)
if a is not None:
counts[idx[a]] += 1
n_parsed = int(counts.sum())
pmass = n_parsed / len(texts)
p = counts / n_parsed if n_parsed else np.full(A, np.nan) # NaN at collapse, by design
with np.errstate(divide="ignore"):
lp = np.log(p)
out.append({
"id": it.id, "frame": it.frame, "lp": lp, "p": p, "pmass_allowed": pmass,
"dimension": it.dimension, "sign": it.sign, "human_label": it.human_label,
"n_samples": len(texts), "n_parsed": n_parsed,
})
if verbose_first and it_n == 0:
logger.debug(
f"\n=== TRACE read_api first item ({instr.name}, {model}, N={len(texts)}) ===\n"
f"--- prompt ---\n{prompt}\n"
f"--- first 3 raw replies ---\n{texts[:3]}\n"
f"--- counts over {space} ---\n{counts.tolist()} parsed={n_parsed}/{len(texts)}\n"
f"SHOULD: replies are a bare token in {space}; parse rate near 1.0 -> coherent. "
f"ELSE the model is refusing / adding prose / the option set is off.\n")
return out