gashaker commited on
Commit
9ebcba2
·
verified ·
1 Parent(s): 9765dc7

Add model card and forecast script

Browse files
Files changed (2) hide show
  1. README.md +20 -52
  2. 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 comparable run of hosted models on the same kind of workload cost about $0.025 per call, and that bill grows with volume. A local forward pass does not.
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. Hosted forecasters typically reason in long form and only then yield a probability — one typical call in the same comparison emitted 618 tokens before the number.
24
 
25
- **No third-party relay.** Scoring does not depend on an outside API, a proxy, or someone else's rate limit. In the comparison run, hosted endpoints dropped coverage and failed calls. This model answered every item it was given.
26
 
27
- **The weights are yours to keep training.** 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. A closed model can only be waited on.
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
- The prompt carries the as-of date, the evidence, the question, and the resolution rule. One forward pass reads P(YES) from the next-token logits. No tokens are generated.
55
 
56
  ```python
57
- import torch
58
- from transformers import AutoProcessor, Qwen3_5ForConditionalGeneration
59
-
60
- model_id = "YarrowLab/yarrow-forecast"
61
-
62
- model = Qwen3_5ForConditionalGeneration.from_pretrained(
63
- model_id,
64
- dtype="auto",
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
- inputs = inputs.to(model.device)
97
-
98
- with torch.no_grad():
99
- logits = model(**inputs).logits[0, -1]
100
 
101
- yes_id = tokenizer.encode("YES", add_special_tokens=False)
102
- no_id = tokenizer.encode("NO", add_special_tokens=False)
103
- pair = torch.stack([logits[yes_id[0]], logits[no_id[0]]])
104
- p_yes = torch.softmax(pair, dim=0)[0].item()
105
- print(p_yes) # probability in [0, 1]
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)