Add model card and forecast script
Browse files- README.md +20 -52
- forecast.py +88 -0
README.md
CHANGED
|
@@ -18,13 +18,13 @@ The model does not write an answer and does not abstain. It reads the probabilit
|
|
| 18 |
|
| 19 |
## Why use it
|
| 20 |
|
| 21 |
-
**The marginal cost is about zero.** The weights sit on your own GPU. A
|
| 22 |
|
| 23 |
-
**One forward pass, no generated text.** Measured batch inference is 0.12–1.6 seconds per question. There is no sampling and no token-by-token decode.
|
| 24 |
|
| 25 |
-
**No third-party
|
| 26 |
|
| 27 |
-
**The weights
|
| 28 |
|
| 29 |
## Model details
|
| 30 |
|
|
@@ -51,58 +51,26 @@ The model does not write an answer and does not abstain. It reads the probabilit
|
|
| 51 |
|
| 52 |
## Usage
|
| 53 |
|
| 54 |
-
|
| 55 |
|
| 56 |
```python
|
| 57 |
-
import
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
device_map="auto",
|
| 66 |
-
)
|
| 67 |
-
processor = AutoProcessor.from_pretrained(model_id)
|
| 68 |
-
tokenizer = processor.tokenizer
|
| 69 |
-
|
| 70 |
-
messages = [
|
| 71 |
-
{
|
| 72 |
-
"role": "user",
|
| 73 |
-
"content": [
|
| 74 |
-
{
|
| 75 |
-
"type": "text",
|
| 76 |
-
"text": (
|
| 77 |
-
"Forecast as of 2026-09-05.\n\n"
|
| 78 |
-
"EVIDENCE:\n"
|
| 79 |
-
"[E1] ...\n\n"
|
| 80 |
-
"Question: Will candidate X win the nomination?\n"
|
| 81 |
-
"Resolution: the nominee is the candidate who wins the party's nomination.\n"
|
| 82 |
-
"Answer YES or NO."
|
| 83 |
-
),
|
| 84 |
-
}
|
| 85 |
-
],
|
| 86 |
-
}
|
| 87 |
-
]
|
| 88 |
-
|
| 89 |
-
inputs = processor.apply_chat_template(
|
| 90 |
-
messages,
|
| 91 |
-
tokenize=True,
|
| 92 |
-
add_generation_prompt=True,
|
| 93 |
-
return_dict=True,
|
| 94 |
-
return_tensors="pt",
|
| 95 |
)
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
with torch.no_grad():
|
| 99 |
-
logits = model(**inputs).logits[0, -1]
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
```
|
| 107 |
|
| 108 |
## Evaluation
|
|
|
|
| 18 |
|
| 19 |
## Why use it
|
| 20 |
|
| 21 |
+
**The marginal cost is about zero.** The weights sit on your own GPU. A forward pass does not incur a per-call fee.
|
| 22 |
|
| 23 |
+
**One forward pass, no generated text.** Measured batch inference is 0.12–1.6 seconds per question. There is no sampling and no token-by-token decode. The probability is read from the next-token distribution.
|
| 24 |
|
| 25 |
+
**No third-party service.** Scoring does not depend on an outside API, a proxy, or a rate limit. The model answered every item in the evaluation.
|
| 26 |
|
| 27 |
+
**The weights can be trained further.** A LoRA update followed by a merge left yes/no calibration error at 0.001–0.003. On 15 other checked items, the answers were unchanged after the merge.
|
| 28 |
|
| 29 |
## Model details
|
| 30 |
|
|
|
|
| 51 |
|
| 52 |
## Usage
|
| 53 |
|
| 54 |
+
`forecast.py` loads the weights once. Each call returns only the calibrated probability.
|
| 55 |
|
| 56 |
```python
|
| 57 |
+
from forecast import Forecaster
|
| 58 |
+
|
| 59 |
+
model = Forecaster("YarrowLab/yarrow-forecast")
|
| 60 |
+
p = model.forecast(
|
| 61 |
+
title="Will candidate X win the nomination?",
|
| 62 |
+
rules="Resolves YES if candidate X is declared the nominee.",
|
| 63 |
+
evidence="[E1] ...",
|
| 64 |
+
as_of="2026-09-05",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
)
|
| 66 |
+
print(p) # 0.71
|
| 67 |
+
```
|
|
|
|
|
|
|
| 68 |
|
| 69 |
+
```python
|
| 70 |
+
ps = model.forecast_batch([
|
| 71 |
+
{"title": "...", "rules": "...", "evidence": "...", "as_of": "2026-09-05"},
|
| 72 |
+
{"title": "...", "rules": "...", "evidence": "...", "as_of": "2026-09-05"},
|
| 73 |
+
])
|
| 74 |
```
|
| 75 |
|
| 76 |
## Evaluation
|
forecast.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Yes/no forecast. One forward pass, then the fitted map. Returns P(YES) only."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
import string
|
| 7 |
+
|
| 8 |
+
SYSTEM_PROMPT = "Apply the criterion to the evidence. Choose exactly one listed option. Answer with its letter only."
|
| 9 |
+
THINK_OFF = "<think>\n\n</think>\n\n"
|
| 10 |
+
TEMPERATURE = 1.421
|
| 11 |
+
# Fitted on the primary-election evaluation.
|
| 12 |
+
A = 1.099406044975045
|
| 13 |
+
C = 1.3783573144958756
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _logit(p: float) -> float:
|
| 17 |
+
p = min(max(p, 1e-6), 1 - 1e-6)
|
| 18 |
+
return math.log(p / (1 - p))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _sigmoid(x: float) -> float:
|
| 22 |
+
return 1.0 / (1.0 + math.exp(-x))
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _letter_ids(tokenizer) -> list[int]:
|
| 26 |
+
probe = [
|
| 27 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 28 |
+
{"role": "user", "content": "Evidence:\n(none)\n\nQuestion: q\nOptions:\nA) a\nB) b"},
|
| 29 |
+
]
|
| 30 |
+
text = tokenizer.apply_chat_template(probe, tokenize=False, add_generation_prompt=True, enable_thinking=False)
|
| 31 |
+
if not text.endswith(THINK_OFF):
|
| 32 |
+
raise ValueError("chat template is missing the expected end marker")
|
| 33 |
+
base = tokenizer.encode(text, add_special_tokens=False)
|
| 34 |
+
ids = []
|
| 35 |
+
for letter in string.ascii_uppercase:
|
| 36 |
+
piece = tokenizer.encode(letter, add_special_tokens=False)
|
| 37 |
+
if len(piece) != 1 or tokenizer.encode(text + letter, add_special_tokens=False) != base + piece:
|
| 38 |
+
raise ValueError(f"{letter!r} is not a single token for this tokenizer")
|
| 39 |
+
ids.append(piece[0])
|
| 40 |
+
return ids
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class Forecaster:
|
| 44 |
+
"""Load once. forecast(...) returns a float in [0, 1]."""
|
| 45 |
+
|
| 46 |
+
def __init__(self, model_path: str = "YarrowLab/yarrow-forecast"):
|
| 47 |
+
import torch
|
| 48 |
+
from transformers import AutoTokenizer, Qwen3_5ForConditionalGeneration
|
| 49 |
+
|
| 50 |
+
self._torch = torch
|
| 51 |
+
self.tok = AutoTokenizer.from_pretrained(model_path)
|
| 52 |
+
self.model = Qwen3_5ForConditionalGeneration.from_pretrained(
|
| 53 |
+
model_path, dtype="auto", device_map="auto",
|
| 54 |
+
).eval()
|
| 55 |
+
self._yes, self._no = _letter_ids(self.tok)[:2]
|
| 56 |
+
|
| 57 |
+
def forecast(self, title: str, rules: str, evidence: str = "", as_of: str | None = None) -> float:
|
| 58 |
+
return self.forecast_batch([{
|
| 59 |
+
"title": title, "rules": rules, "evidence": evidence, "as_of": as_of,
|
| 60 |
+
}])[0]
|
| 61 |
+
|
| 62 |
+
def forecast_batch(self, questions: list[dict]) -> list[float]:
|
| 63 |
+
return [self._one(q) for q in questions]
|
| 64 |
+
|
| 65 |
+
def _one(self, q: dict) -> float:
|
| 66 |
+
as_of = q.get("as_of") or "the question date"
|
| 67 |
+
state = f"Forecast as of {as_of}."
|
| 68 |
+
if q.get("evidence"):
|
| 69 |
+
state += "\n\nEVIDENCE AVAILABLE AT FORECAST TIME:\n" + q["evidence"]
|
| 70 |
+
question = (
|
| 71 |
+
f"{q['title']}\n\nResolution rules (verbatim): {q['rules']}\n\n"
|
| 72 |
+
"Will this resolve YES, based only on the evidence above?"
|
| 73 |
+
)
|
| 74 |
+
state = state if state.strip() else "(none)"
|
| 75 |
+
messages = [
|
| 76 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 77 |
+
{"role": "user", "content": "Evidence:\n" + state + "\n\nQuestion: " + question.strip() + "\nOptions:\nA) yes\nB) no"},
|
| 78 |
+
]
|
| 79 |
+
text = self.tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True, enable_thinking=False)
|
| 80 |
+
if not text.endswith(THINK_OFF):
|
| 81 |
+
raise ValueError("chat template is missing the expected end marker")
|
| 82 |
+
ids = self.tok.encode(text, add_special_tokens=False)
|
| 83 |
+
input_ids = self._torch.tensor([ids], device=self.model.device)
|
| 84 |
+
with self._torch.no_grad():
|
| 85 |
+
logits = self.model(input_ids=input_ids).logits[0, -1].float()
|
| 86 |
+
pair = self._torch.stack([logits[self._yes], logits[self._no]])
|
| 87 |
+
yes = self._torch.softmax(pair / TEMPERATURE, dim=0)[0].item()
|
| 88 |
+
return _sigmoid(A * _logit(yes) + C)
|