gramajo commited on
Commit
ebfc252
Β·
1 Parent(s): b50b90e

Nouns proposal calibration tool

Browse files
Files changed (3) hide show
  1. README.md +36 -7
  2. app.py +206 -0
  3. requirements.txt +5 -0
README.md CHANGED
@@ -1,15 +1,44 @@
1
  ---
2
  title: Nouns Proposal Check
3
- emoji: πŸ“‰
4
- colorFrom: indigo
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.20.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
- short_description: Check the quality of your Nouns Proposal
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Nouns Proposal Check
3
+ emoji: πŸ€“
4
+ colorFrom: red
5
+ colorTo: gray
6
  sdk: gradio
7
+ sdk_version: 4.44.0
 
8
  app_file: app.py
9
  pinned: false
10
  license: mit
 
11
  ---
12
 
13
+ # Nouns Proposal Check
14
+
15
+ A calibration tool for Nouns DAO proposers. It does **not** predict whether your
16
+ proposal will pass β€” the underlying model isn't accurate enough for that, and
17
+ saying so is the point.
18
+
19
+ It shows you three things:
20
+
21
+ 1. **The current base rate.** After the BreakEven bloc began voting down spend
22
+ (~proposal #786), the pass rate fell from ~56% to ~28%. Most proposers don't
23
+ know this number. It's the single most useful fact here.
24
+ 2. **Where your proposal ranks** against past proposals in the current regime.
25
+ 3. **The most similar past proposals** and what happened to them β€” go read them.
26
+
27
+ ## The finding behind it
28
+
29
+ A DistilBERT model fine-tuned on 982 Nouns proposals reaches AUC 0.65 β€” real but
30
+ weak ranking signal. That signal *survives* the BreakEven regime change: the model
31
+ still ranks proposals about as well as before. What broke is calibration. Pass
32
+ probability collapsed, and a model trained on the old regime is systematically
33
+ overconfident about the new one.
34
+
35
+ **BreakEven didn't change what makes a proposal good. It changed how good a
36
+ proposal has to be.**
37
+
38
+ ## Verify it
39
+
40
+ - Model: https://huggingface.co/gramajo/nouns-proposal-predictor
41
+ - Dataset: https://huggingface.co/datasets/gramajo/nouns-proposals
42
+ - Exact split IDs + raw metrics: `splits.json` / `results.json` in the model repo
43
+
44
+ Trust but verify. Open a discussion if you find a flaw.
app.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Nouns Proposal Check β€” a calibration tool, not an oracle.
3
+
4
+ Deliberately does NOT output a pass/fail verdict. The underlying model has an
5
+ AUC of ~0.65: real ranking signal, nowhere near enough to tell an individual
6
+ person their proposal will fail. So instead it reports:
7
+
8
+ 1. the current base rate (what you're actually up against)
9
+ 2. where the proposal ranks against past proposals (percentile β€” the thing
10
+ AUC actually supports)
11
+ 3. the most similar past proposals and what happened to them (verifiable,
12
+ actionable, and requires no faith in the model's calibration)
13
+ """
14
+
15
+ import json
16
+ import gradio as gr
17
+ import numpy as np
18
+ import torch
19
+ import torch.nn.functional as F
20
+ from datasets import load_dataset
21
+ from transformers import AutoTokenizer, AutoModelForSequenceClassification
22
+
23
+ MODEL_ID = "gramajo/nouns-proposal-predictor"
24
+ DATASET_ID = "gramajo/nouns-proposals"
25
+ BREAK_ID = 786 # BreakEven bloc regime change
26
+ MAX_LENGTH = 512
27
+ AUC = 0.65 # measured, run A (stratified random split)
28
+ ACC, BASELINE = 0.589, 0.502
29
+
30
+ print("Loading model...")
31
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
32
+ model = AutoModelForSequenceClassification.from_pretrained(MODEL_ID)
33
+ model.eval()
34
+
35
+ print("Loading corpus...")
36
+ ds = load_dataset(DATASET_ID)
37
+ CORPUS = sorted(
38
+ [dict(r) for r in list(ds["train"]) + list(ds["test"])],
39
+ key=lambda r: int(r["id"]),
40
+ )
41
+ for r in CORPUS:
42
+ r["passed"] = int(r["passed"])
43
+
44
+ POST = [r for r in CORPUS if int(r["id"]) >= BREAK_ID]
45
+ CURRENT_BASE_RATE = sum(r["passed"] for r in POST) / len(POST)
46
+ HISTORIC_BASE_RATE = sum(r["passed"] for r in CORPUS if int(r["id"]) < BREAK_ID) / max(
47
+ 1, len([r for r in CORPUS if int(r["id"]) < BREAK_ID])
48
+ )
49
+
50
+
51
+ def _encode(texts, batch=16):
52
+ """Return (pass_prob, CLS embedding) for each text."""
53
+ probs, embs = [], []
54
+ for i in range(0, len(texts), batch):
55
+ chunk = texts[i : i + batch]
56
+ enc = tokenizer(
57
+ chunk, padding=True, truncation=True,
58
+ max_length=MAX_LENGTH, return_tensors="pt",
59
+ )
60
+ with torch.no_grad():
61
+ out = model(**enc, output_hidden_states=True)
62
+ probs.extend(F.softmax(out.logits, dim=-1)[:, 1].tolist())
63
+ # last hidden layer, [CLS] token
64
+ embs.append(out.hidden_states[-1][:, 0, :].cpu().numpy())
65
+ return np.array(probs), np.vstack(embs)
66
+
67
+
68
+ print("Scoring corpus (one-time, ~1-2 min on CPU)...")
69
+ _texts = [(r["title"] + " " + (r.get("description") or ""))[:2000] for r in CORPUS]
70
+ CORPUS_PROBS, CORPUS_EMBS = _encode(_texts)
71
+ _norms = np.linalg.norm(CORPUS_EMBS, axis=1, keepdims=True)
72
+ CORPUS_EMBS_N = CORPUS_EMBS / np.clip(_norms, 1e-9, None)
73
+ POST_PROBS = np.array([CORPUS_PROBS[i] for i, r in enumerate(CORPUS) if int(r["id"]) >= BREAK_ID])
74
+ print("Ready.")
75
+
76
+
77
+ def analyze(title, description):
78
+ if not title.strip() and not description.strip():
79
+ return "Enter a proposal title and description to see how it compares."
80
+
81
+ text = (title + " " + description)[:2000]
82
+ prob, emb = _encode([text])
83
+ prob = float(prob[0])
84
+ emb_n = emb[0] / max(np.linalg.norm(emb[0]), 1e-9)
85
+
86
+ # Percentile against the CURRENT regime -- ranking is what AUC supports.
87
+ pct = float((POST_PROBS < prob).mean() * 100)
88
+
89
+ # Nearest neighbours by cosine similarity.
90
+ sims = CORPUS_EMBS_N @ emb_n
91
+ top = np.argsort(-sims)[:5]
92
+
93
+ md = []
94
+ md.append("## What you're up against\n")
95
+ md.append(
96
+ f"In the current regime (proposals #{BREAK_ID}+, after the BreakEven bloc "
97
+ f"began voting down spend), **{CURRENT_BASE_RATE:.0%} of proposals pass.** "
98
+ f"Before that, it was {HISTORIC_BASE_RATE:.0%}.\n"
99
+ )
100
+ md.append(
101
+ "The bar moved. What makes a proposal *good* didn't change much β€” how good "
102
+ "it has to be did.\n"
103
+ )
104
+
105
+ md.append("\n## Where yours ranks\n")
106
+ md.append(
107
+ f"Your proposal scores higher than **{pct:.0f}%** of proposals submitted in "
108
+ f"the current regime.\n"
109
+ )
110
+ if pct >= 75:
111
+ md.append(
112
+ "\nThat's in the upper quartile of what the model has seen. It is **not** "
113
+ "a prediction that you'll pass β€” most proposals in this regime fail "
114
+ "regardless of where they rank.\n"
115
+ )
116
+ elif pct >= 40:
117
+ md.append(
118
+ "\nMiddle of the pack. Worth looking hard at the similar proposals below, "
119
+ "especially the ones that failed.\n"
120
+ )
121
+ else:
122
+ md.append(
123
+ "\nLower end of the distribution. That doesn't mean it *will* fail β€” but "
124
+ "it's worth understanding why similar proposals didn't land.\n"
125
+ )
126
+
127
+ md.append("\n## Most similar past proposals\n")
128
+ md.append("Read these. They're more informative than any score this tool prints.\n\n")
129
+ md.append("| outcome | similarity | proposal |\n|---|---|---|\n")
130
+ for i in top:
131
+ r = CORPUS[i]
132
+ outcome = "βœ… passed" if r["passed"] else "❌ failed"
133
+ era = "post-BreakEven" if int(r["id"]) >= BREAK_ID else "pre-BreakEven"
134
+ t = r["title"][:70]
135
+ md.append(f"| {outcome} | {sims[i]:.2f} | **#{r['id']}** {t} <br/><sub>{era}</sub> |\n")
136
+
137
+ md.append(
138
+ f"\n---\n\n*Model AUC {AUC:.2f} β€” it ranks proposals better than chance, but it "
139
+ f"is **not** accurate enough to tell you whether your proposal will pass. "
140
+ f"Treat the ranking as a weak signal and the similar-proposal list as the "
141
+ f"actual output.*"
142
+ )
143
+ return "".join(md)
144
+
145
+
146
+ LIMITATIONS = f"""
147
+ ### How this works, and where it fails
148
+
149
+ This is a fine-tuned DistilBERT that read {len(CORPUS)} past Nouns DAO proposals
150
+ (title + description only) and learned to rank them.
151
+
152
+ **Measured performance**, on a stratified random split:
153
+
154
+ | metric | value | meaning |
155
+ |---|---|---|
156
+ | AUC | {AUC:.2f} | Given a passing and a failing proposal, it ranks them correctly ~65% of the time. Real signal, but weak. |
157
+ | accuracy | {ACC:.1%} | Against a majority-class baseline of {BASELINE:.1%}. It beats "always guess," but not by much. |
158
+
159
+ **Why there is no pass/fail verdict here.** At AUC 0.65, a confident verdict would
160
+ be wrong roughly a third of the time. Telling someone who spent three weeks on a
161
+ proposal that it "will fail" β€” and being wrong that often β€” would discourage good
162
+ proposals and teach people to write toward the model instead of toward the DAO.
163
+
164
+ **What the model cannot see:** who is proposing, whether they've shipped before,
165
+ how much ETH they're asking for, what the treasury looked like, what happened in
166
+ Discord beforehand, who showed up to vote. Those almost certainly matter more than
167
+ the prose. This model reads only the text.
168
+
169
+ **Goodhart warning.** If you optimize your proposal to score well here, you are
170
+ optimizing for *resemblance to proposals that passed*, not for quality. That is a
171
+ good way to produce a monoculture. Use the similar-proposals list to learn; don't
172
+ tune your wording against the percentile.
173
+
174
+ **Everything is open. Verify it:**
175
+ - Model: [{MODEL_ID}](https://huggingface.co/{MODEL_ID})
176
+ - Dataset: [{DATASET_ID}](https://huggingface.co/datasets/{DATASET_ID})
177
+ - Exact split IDs and raw metrics are in the model repo (`splits.json`, `results.json`)
178
+
179
+ Found a flaw? Open a discussion on the model repo. That's the point.
180
+ """
181
+
182
+ with gr.Blocks(title="Nouns Proposal Check") as demo:
183
+ gr.Markdown(
184
+ "# Nouns Proposal Check\n"
185
+ "**Not a verdict machine.** This won't tell you whether your proposal will pass β€” "
186
+ "the model isn't good enough for that, and I'd rather say so than pretend otherwise.\n\n"
187
+ "What it *will* do: show you the current pass rate, where your proposal ranks "
188
+ "against past ones, and the most similar proposals that came before β€” so you can "
189
+ "go read what worked and what didn't."
190
+ )
191
+
192
+ with gr.Row():
193
+ with gr.Column(scale=1):
194
+ title = gr.Textbox(label="Proposal title", placeholder="Nouns Γ— ...", lines=1)
195
+ desc = gr.Textbox(label="Proposal description", lines=14,
196
+ placeholder="Paste the full proposal body here...")
197
+ btn = gr.Button("Compare against past proposals", variant="primary")
198
+ with gr.Column(scale=1):
199
+ out = gr.Markdown("Enter a proposal to see how it compares.")
200
+
201
+ btn.click(analyze, inputs=[title, desc], outputs=out)
202
+
203
+ with gr.Accordion("Limitations, metrics, and how to verify this", open=False):
204
+ gr.Markdown(LIMITATIONS)
205
+
206
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=4.44.0
2
+ transformers>=4.44.0
3
+ torch>=2.2.0
4
+ datasets>=2.20.0
5
+ numpy>=1.26.0