greengate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
greengate/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ from greengate.core import GreenGate, RouteResult
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["GreenGate", "RouteResult"]
5
+
6
+
7
+ def __getattr__(name):
8
+ # legacy demo class, loaded lazily to keep `import greengate` light
9
+ if name == "GreenGateRouter":
10
+ from greengate.router import GreenGateRouter
11
+ return GreenGateRouter
12
+ raise AttributeError(name)
greengate/api_tier.py ADDED
@@ -0,0 +1,153 @@
1
+ """Large-tier inference via OpenAI API with EcoLogits carbon estimation.
2
+
3
+ Dual-Mode Carbon Profiler, API side: local models are measured with
4
+ pynvml (profiler.py); API models are estimated with EcoLogits
5
+ (Rince et al., 2025, JOSS). If EcoLogits is unavailable or fails,
6
+ falls back to a documented per-token estimate and flags it.
7
+ """
8
+
9
+ import os
10
+ import time
11
+ from dataclasses import dataclass
12
+
13
+ # Fallback constants (used ONLY if EcoLogits fails) — documented in thesis:
14
+ # Jegham et al. (2025) measure ~0.42 Wh for a mean GPT-4o query (~150 output
15
+ # tokens). GPT-4o-mini is a distilled/smaller deployment; we conservatively
16
+ # assume 0.5x the 4o figure, scaled linearly by output tokens.
17
+ FALLBACK_WH_PER_OUTPUT_TOKEN = 0.42 * 0.5 / 150 # = 0.0014 Wh/token
18
+ WORLD_CARBON_INTENSITY = 475.0 # gCO2/kWh
19
+ DATACENTER_PUE = 1.2
20
+
21
+
22
+ @dataclass
23
+ class APIResult:
24
+ response: str
25
+ model: str
26
+ input_tokens: int
27
+ output_tokens: int
28
+ latency_s: float
29
+ energy_wh: float
30
+ carbon_grams: float
31
+ carbon_source: str # "ecologits" or "fallback_estimate"
32
+ # confidence signals from top-20 logprobs (None unless logprobs=True):
33
+ avg_logprob: float | None = None # mean log P(chosen token)
34
+ trunc_entropy: float | None = None # mean entropy over renormalised top-20 (bits)
35
+
36
+
37
+ class APILargeTier:
38
+ def __init__(self, model: str = "gpt-4o-mini", max_tokens: int = 300,
39
+ dry_run: bool = False, logprobs: bool = False):
40
+ self.model = model
41
+ self.max_tokens = max_tokens
42
+ self.dry_run = dry_run
43
+ self.logprobs = logprobs
44
+ self._ecologits_ok = False
45
+
46
+ if dry_run:
47
+ return
48
+
49
+ # EcoLogits must be initialised BEFORE the OpenAI client is created.
50
+ try:
51
+ from ecologits import EcoLogits
52
+ EcoLogits.init(providers=["openai"])
53
+ self._ecologits_ok = True
54
+ except Exception as e:
55
+ print(f" [api_tier] EcoLogits unavailable ({e}) — using documented fallback estimate")
56
+
57
+ from openai import OpenAI
58
+ self.client = OpenAI() # reads OPENAI_API_KEY env var
59
+
60
+ def query_vision(self, prompt: str, image) -> APIResult:
61
+ """Vision escalation: PIL image sent as a base64 data URL."""
62
+ if self.dry_run:
63
+ return self.query(prompt)
64
+ import base64
65
+ import io
66
+ buf = io.BytesIO()
67
+ image.save(buf, format="JPEG", quality=85)
68
+ b64 = base64.b64encode(buf.getvalue()).decode()
69
+ content = [
70
+ {"type": "text", "text": prompt},
71
+ {"type": "image_url",
72
+ "image_url": {"url": f"data:image/jpeg;base64,{b64}"}},
73
+ ]
74
+ return self._chat(content)
75
+
76
+ def query(self, prompt: str) -> APIResult:
77
+ if self.dry_run:
78
+ return APIResult(
79
+ response="[DRY RUN — no API call made]",
80
+ model=self.model, input_tokens=len(prompt) // 4,
81
+ output_tokens=50, latency_s=0.5,
82
+ energy_wh=50 * FALLBACK_WH_PER_OUTPUT_TOKEN,
83
+ carbon_grams=50 * FALLBACK_WH_PER_OUTPUT_TOKEN / 1000
84
+ * DATACENTER_PUE * WORLD_CARBON_INTENSITY,
85
+ carbon_source="dry_run",
86
+ )
87
+
88
+ return self._chat(prompt)
89
+
90
+ def _chat(self, content, retries: int = 6) -> APIResult:
91
+ kwargs = {}
92
+ if self.logprobs:
93
+ kwargs = {"logprobs": True, "top_logprobs": 20}
94
+ from openai import RateLimitError, APIError
95
+ t0 = time.perf_counter()
96
+ for attempt in range(retries):
97
+ try:
98
+ resp = self.client.chat.completions.create(
99
+ model=self.model,
100
+ messages=[{"role": "user", "content": content}],
101
+ max_tokens=self.max_tokens,
102
+ **kwargs,
103
+ )
104
+ break
105
+ except (RateLimitError, APIError):
106
+ if attempt == retries - 1:
107
+ raise
108
+ time.sleep(min(2 ** attempt, 30)) # 1,2,4,8,16,30s backoff
109
+ latency = time.perf_counter() - t0
110
+
111
+ avg_lp = trunc_H = None
112
+ lp_content = getattr(resp.choices[0].logprobs, "content", None) \
113
+ if self.logprobs and resp.choices[0].logprobs else None
114
+ if lp_content:
115
+ import math
116
+ lps, ents = [], []
117
+ for tok in lp_content:
118
+ lps.append(tok.logprob)
119
+ if tok.top_logprobs:
120
+ ps = [math.exp(t.logprob) for t in tok.top_logprobs]
121
+ z = sum(ps) or 1.0
122
+ ents.append(-sum(p / z * math.log2(p / z) for p in ps if p > 0))
123
+ avg_lp = sum(lps) / len(lps) if lps else None
124
+ trunc_H = sum(ents) / len(ents) if ents else None
125
+
126
+ text = resp.choices[0].message.content or ""
127
+ in_tok = resp.usage.prompt_tokens
128
+ out_tok = resp.usage.completion_tokens
129
+
130
+ energy_wh, carbon_g, source = None, None, "fallback_estimate"
131
+ if self._ecologits_ok and hasattr(resp, "impacts"):
132
+ try:
133
+ # EcoLogits attaches .impacts; energy in kWh (value or range)
134
+ e = resp.impacts.energy.value
135
+ energy_kwh = (e.min + e.max) / 2 if hasattr(e, "min") else float(e)
136
+ g = resp.impacts.gwp.value
137
+ gwp_kg = (g.min + g.max) / 2 if hasattr(g, "min") else float(g)
138
+ energy_wh = energy_kwh * 1000
139
+ carbon_g = gwp_kg * 1000
140
+ source = "ecologits"
141
+ except Exception:
142
+ pass
143
+ if energy_wh is None:
144
+ energy_wh = out_tok * FALLBACK_WH_PER_OUTPUT_TOKEN
145
+ carbon_g = energy_wh / 1000 * DATACENTER_PUE * WORLD_CARBON_INTENSITY
146
+
147
+ return APIResult(
148
+ response=text.strip(), model=self.model,
149
+ input_tokens=in_tok, output_tokens=out_tok,
150
+ latency_s=latency, energy_wh=energy_wh,
151
+ carbon_grams=carbon_g, carbon_source=source,
152
+ avg_logprob=avg_lp, trunc_entropy=trunc_H,
153
+ )
greengate/budget.py ADDED
@@ -0,0 +1,31 @@
1
+ """Sliding-window carbon budget (Chapter 3, Design Analysis 2.5.3).
2
+
3
+ Maintains a window of the last `window_s` seconds of routing decisions.
4
+ If the carbon spent inside the window would exceed budget K, escalations
5
+ are temporarily blocked (the query falls back to the small answer) until
6
+ older queries age out of the window.
7
+ """
8
+
9
+ from collections import deque
10
+
11
+
12
+ class SlidingWindowBudget:
13
+ def __init__(self, budget_g: float, window_s: float = 3600.0):
14
+ self.budget_g = budget_g
15
+ self.window_s = window_s
16
+ self._events: deque[tuple[float, float]] = deque() # (timestamp, carbon_g)
17
+
18
+ def _expire(self, now: float):
19
+ while self._events and self._events[0][0] < now - self.window_s:
20
+ self._events.popleft()
21
+
22
+ def window_carbon(self, now: float) -> float:
23
+ self._expire(now)
24
+ return sum(c for _, c in self._events)
25
+
26
+ def allows(self, now: float, escalation_cost_g: float) -> bool:
27
+ """Would escalating now keep the window under budget?"""
28
+ return self.window_carbon(now) + escalation_cost_g <= self.budget_g
29
+
30
+ def record(self, now: float, carbon_g: float):
31
+ self._events.append((now, carbon_g))
@@ -0,0 +1,53 @@
1
+ """Temperature scaling calibration (Guo et al., 2017).
2
+
3
+ Fits a single scalar T on a held-out validation split by minimising
4
+ negative log-likelihood. T > 1 softens overconfident distributions.
5
+ Reports Expected Calibration Error (ECE) before and after.
6
+ """
7
+
8
+ import torch
9
+ import torch.nn.functional as F
10
+
11
+
12
+ def fit_temperature(logits: torch.Tensor, labels: torch.Tensor,
13
+ t_min: float = 0.25, t_max: float = 8.0,
14
+ n_grid: int = 400) -> float:
15
+ """Fit T by fine grid search over NLL (robust, dependency-free).
16
+
17
+ Args:
18
+ logits: (n, k) raw choice logits from the validation split
19
+ labels: (n,) correct choice indices
20
+ """
21
+ best_t, best_nll = 1.0, float("inf")
22
+ for i in range(n_grid):
23
+ t = t_min + (t_max - t_min) * i / (n_grid - 1)
24
+ nll = F.cross_entropy(logits / t, labels).item()
25
+ if nll < best_nll:
26
+ best_nll, best_t = nll, t
27
+ return best_t
28
+
29
+
30
+ def ece(logits: torch.Tensor, labels: torch.Tensor,
31
+ temperature: float = 1.0, n_bins: int = 15) -> float:
32
+ """Expected Calibration Error over equal-width confidence bins."""
33
+ probs = F.softmax(logits / temperature, dim=-1)
34
+ conf, pred = probs.max(dim=-1)
35
+ correct = (pred == labels).float()
36
+
37
+ total = 0.0
38
+ n = len(labels)
39
+ for b in range(n_bins):
40
+ lo, hi = b / n_bins, (b + 1) / n_bins
41
+ mask = (conf > lo) & (conf <= hi)
42
+ if mask.sum() == 0:
43
+ continue
44
+ avg_conf = conf[mask].mean().item()
45
+ avg_acc = correct[mask].mean().item()
46
+ total += (mask.sum().item() / n) * abs(avg_conf - avg_acc)
47
+ return total
48
+
49
+
50
+ def calibrated_entropy(logits: torch.Tensor, temperature: float) -> float:
51
+ """Shannon entropy (bits) of the temperature-scaled distribution."""
52
+ probs = F.softmax(logits / temperature, dim=-1).clamp(min=1e-9)
53
+ return -(probs * probs.log2()).sum(dim=-1).item()
greengate/core.py ADDED
@@ -0,0 +1,280 @@
1
+ """GreenGate — the public library API.
2
+
3
+ import greengate
4
+ gw = greengate.GreenGate(small="Qwen/Qwen2.5-0.5B-Instruct",
5
+ large="gpt-4o-mini", budget_g=0.5)
6
+ r = gw.route("Summarise this document ...")
7
+ r.response, r.decision, r.carbon_g
8
+ gw.profile() # session totals: carbon, escalation rate, wasted cost
9
+ gw.calibrate() # one-time temperature fit for unlisted small models
10
+
11
+ Tier rules (by design, see thesis Ch.3):
12
+ small — any open-weight transformers model, runs locally (the entropy
13
+ signal needs token logits, which APIs do not expose)
14
+ large — a local transformers model ("org/name") OR an OpenAI API model
15
+ name ("gpt-4o-mini")
16
+ """
17
+
18
+ import json
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+
23
+ PRESETS_PATH = Path(__file__).parent / "presets.json"
24
+ USER_CALIBRATIONS = Path.home() / ".greengate" / "calibrations.json"
25
+
26
+ # threshold percentile used while auto-tuning on the user's own traffic
27
+ AUTO_THRESHOLD_PERCENTILE = {"green": 80, "balanced": 60, "quality": 35}
28
+ WARMUP_QUERIES = 20
29
+
30
+
31
+ @dataclass
32
+ class RouteResult:
33
+ response: str
34
+ decision: str # "LOCAL" or "ESCALATE" (or "LOCAL(budget)")
35
+ signal: float # entropy (bits) or semantic entropy
36
+ threshold: float | None
37
+ carbon_g: float # full accounting: includes wasted small run
38
+ wasted_carbon_g: float
39
+ latency_s: float
40
+ small_model: str
41
+ large_model: str
42
+
43
+
44
+ @dataclass
45
+ class _Session:
46
+ queries: int = 0
47
+ escalated: int = 0
48
+ budget_blocked: int = 0
49
+ carbon_g: float = 0.0
50
+ wasted_carbon_g: float = 0.0
51
+ energy_j: float = 0.0
52
+ latency_s: float = 0.0
53
+
54
+
55
+ def _load_temperature(model_name: str) -> tuple[float, str]:
56
+ """Preset registry first, then the user's own calibrations, else 1.0."""
57
+ for path, source in [(PRESETS_PATH, "preset"),
58
+ (USER_CALIBRATIONS, "user-calibrated")]:
59
+ try:
60
+ data = json.loads(path.read_text())
61
+ if model_name in data:
62
+ return float(data[model_name]["temperature"]), source
63
+ except (OSError, json.JSONDecodeError):
64
+ pass
65
+ return 1.0, "uncalibrated"
66
+
67
+
68
+ class GreenGate:
69
+ def __init__(self, small: str = "Qwen/Qwen2.5-0.5B-Instruct",
70
+ large: str = "gpt-4o-mini",
71
+ mode: str = "balanced",
72
+ threshold: float | None = None,
73
+ budget_g: float | None = None,
74
+ budget_window_s: float = 3600.0,
75
+ signal: str = "entropy",
76
+ small_4bit: bool = False,
77
+ large_4bit: bool = True,
78
+ max_new_tokens: int = 200,
79
+ dry_run_api: bool = False,
80
+ large_is_api: bool | None = None):
81
+ if mode not in AUTO_THRESHOLD_PERCENTILE:
82
+ raise ValueError(f"mode must be one of {list(AUTO_THRESHOLD_PERCENTILE)}")
83
+ if signal not in ("entropy", "semantic"):
84
+ raise ValueError("signal must be 'entropy' or 'semantic'")
85
+
86
+ self.small_name, self.large_name = small, large
87
+ self.mode = mode
88
+ self.signal = signal
89
+ self._fixed_threshold = threshold
90
+ self._entropy_history: list[float] = []
91
+
92
+ T, source = _load_temperature(small)
93
+ self.temperature = T
94
+ if source == "uncalibrated":
95
+ print(f"[greengate] no calibration preset for {small} — routing on "
96
+ f"raw entropy with auto-threshold; run gw.calibrate() to fit one")
97
+
98
+ from greengate.textgen import SmallTextModel
99
+ self._small = SmallTextModel(small, temperature_T=T,
100
+ max_new_tokens=max_new_tokens,
101
+ load_in_4bit=small_4bit)
102
+
103
+ if large_is_api is None: # auto-detect: OpenAI naming vs HF hub id
104
+ large_is_api = large.startswith(("gpt-", "o1", "o3", "o4", "chatgpt"))
105
+ self._large_is_api = large_is_api
106
+ if self._large_is_api:
107
+ from greengate.api_tier import APILargeTier
108
+ self._large = APILargeTier(model=large, max_tokens=max_new_tokens,
109
+ dry_run=dry_run_api)
110
+ else:
111
+ self._large = SmallTextModel(large, max_new_tokens=max_new_tokens,
112
+ load_in_4bit=large_4bit)
113
+
114
+ self._budget = None
115
+ if budget_g is not None:
116
+ from greengate.budget import SlidingWindowBudget
117
+ self._budget = SlidingWindowBudget(budget_g, budget_window_s)
118
+
119
+ self._semantic = None # lazy — only if signal="semantic"
120
+ self._session = _Session()
121
+
122
+ # ------------------------------------------------------------------ #
123
+
124
+ def _threshold(self) -> float | None:
125
+ if self._fixed_threshold is not None:
126
+ return self._fixed_threshold
127
+ if len(self._entropy_history) < WARMUP_QUERIES:
128
+ return None # warmup: not enough traffic seen yet
129
+ h = sorted(self._entropy_history)
130
+ pct = AUTO_THRESHOLD_PERCENTILE[self.mode]
131
+ return h[int(pct / 100 * (len(h) - 1))]
132
+
133
+ def _semantic_entropy(self, query: str, k: int = 3) -> tuple[float, float]:
134
+ """(semantic entropy, extra energy J). EXPERIMENTAL — k extra samples."""
135
+ import torch
136
+ if self._semantic is None:
137
+ from greengate.semantic import NLIClusterer
138
+ self._semantic = NLIClusterer()
139
+ answers, extra_j = [], 0.0
140
+ for _ in range(k):
141
+ with torch.no_grad():
142
+ prompt = self._small._chat_wrap(query)
143
+ inputs = self._small.tokenizer(prompt, return_tensors="pt",
144
+ truncation=True, max_length=1024)
145
+ inputs = {kk: v.to(self._small.model.device)
146
+ for kk, v in inputs.items()}
147
+ self._small.profiler.start()
148
+ out = self._small.model.generate(
149
+ **inputs, max_new_tokens=80, do_sample=True,
150
+ temperature=1.0, top_p=0.95,
151
+ pad_token_id=self._small.tokenizer.pad_token_id)
152
+ e, _ = self._small.profiler.stop()
153
+ extra_j += e
154
+ gen = out[0][inputs["input_ids"].shape[1]:]
155
+ answers.append(self._small.tokenizer.decode(
156
+ gen, skip_special_tokens=True).strip())
157
+ from greengate.semantic import semantic_entropy
158
+ return semantic_entropy(self._semantic.cluster(answers)), extra_j
159
+
160
+ # ------------------------------------------------------------------ #
161
+
162
+ def route(self, query: str) -> RouteResult:
163
+ t0 = time.perf_counter()
164
+ small_r = self._small.generate(query)
165
+
166
+ if self.signal == "semantic":
167
+ sig, extra_j = self._semantic_entropy(query)
168
+ extra_c = extra_j / 3_600_000.0 * 1.2 * 475.0
169
+ else:
170
+ sig = small_r.entropy_calibrated
171
+ extra_c = 0.0
172
+ self._entropy_history.append(sig)
173
+
174
+ thr = self._threshold()
175
+ wants_escalation = thr is not None and sig > thr
176
+
177
+ decision = "LOCAL"
178
+ response = small_r.response
179
+ carbon = small_r.carbon_grams + extra_c
180
+ wasted = 0.0
181
+
182
+ if wants_escalation:
183
+ esc_cost_estimate = carbon * 3 # rough pre-check for the budget
184
+ now = time.monotonic()
185
+ if self._budget is not None and not self._budget.allows(now, esc_cost_estimate):
186
+ decision = "LOCAL(budget)"
187
+ self._session.budget_blocked += 1
188
+ else:
189
+ decision = "ESCALATE"
190
+ if self._large_is_api:
191
+ large_r = self._large.query(query)
192
+ large_carbon = large_r.carbon_grams
193
+ response = large_r.response
194
+ else:
195
+ large_r = self._large.generate(query)
196
+ large_carbon = large_r.carbon_grams
197
+ response = large_r.response
198
+ wasted = small_r.carbon_grams # full accounting
199
+ carbon = large_carbon + wasted + extra_c
200
+
201
+ if self._budget is not None:
202
+ self._budget.record(time.monotonic(), carbon)
203
+
204
+ latency = time.perf_counter() - t0
205
+ s = self._session
206
+ s.queries += 1
207
+ s.escalated += decision == "ESCALATE"
208
+ s.carbon_g += carbon
209
+ s.wasted_carbon_g += wasted
210
+ s.latency_s += latency
211
+
212
+ return RouteResult(
213
+ response=response, decision=decision, signal=sig, threshold=thr,
214
+ carbon_g=carbon, wasted_carbon_g=wasted, latency_s=latency,
215
+ small_model=self.small_name, large_model=self.large_name)
216
+
217
+ def profile(self) -> dict:
218
+ s = self._session
219
+ return {
220
+ "queries": s.queries,
221
+ "escalation_rate": s.escalated / s.queries if s.queries else 0.0,
222
+ "budget_blocked": s.budget_blocked,
223
+ "total_carbon_g": round(s.carbon_g, 6),
224
+ "wasted_carbon_g": round(s.wasted_carbon_g, 6),
225
+ "avg_latency_s": round(s.latency_s / s.queries, 3) if s.queries else 0.0,
226
+ "small_model": self.small_name,
227
+ "large_model": self.large_name,
228
+ "signal": self.signal,
229
+ "threshold": self._threshold(),
230
+ }
231
+
232
+ def config(self, threshold: float | None = None,
233
+ budget_g: float | None = None,
234
+ mode: str | None = None):
235
+ if threshold is not None:
236
+ self._fixed_threshold = threshold
237
+ if mode is not None:
238
+ if mode not in AUTO_THRESHOLD_PERCENTILE:
239
+ raise ValueError(f"mode must be one of {list(AUTO_THRESHOLD_PERCENTILE)}")
240
+ self.mode = mode
241
+ if budget_g is not None:
242
+ from greengate.budget import SlidingWindowBudget
243
+ self._budget = SlidingWindowBudget(budget_g, 3600.0)
244
+ return self
245
+
246
+ def calibrate(self, n: int = 150, seed: int = 42) -> dict:
247
+ """Fit temperature scaling for this small model on held-out MMLU
248
+ validation (the thesis methodology), save it under ~/.greengate/."""
249
+ import torch
250
+ from greengate.mmlu import load_mmlu
251
+ from greengate.evaluator import ChoiceEvaluator
252
+ from greengate.calibration import fit_temperature, ece
253
+
254
+ print(f"[greengate] calibrating {self.small_name} on {n} MMLU "
255
+ f"validation questions...")
256
+ ev = ChoiceEvaluator(self.small_name)
257
+ logits, labels = [], []
258
+ for q in load_mmlu(n_questions=n, seed=seed, split="validation"):
259
+ r = ev.evaluate(q)
260
+ logits.append(r.choice_logits)
261
+ labels.append(q.answer_idx)
262
+ lt, lb = torch.tensor(logits), torch.tensor(labels)
263
+ T = fit_temperature(lt, lb)
264
+ result = {"temperature": round(T, 4),
265
+ "ece_before": round(ece(lt, lb, 1.0), 5),
266
+ "ece_after": round(ece(lt, lb, T), 5),
267
+ "fitted_on": f"MMLU validation n={n}"}
268
+
269
+ USER_CALIBRATIONS.parent.mkdir(parents=True, exist_ok=True)
270
+ data = {}
271
+ if USER_CALIBRATIONS.exists():
272
+ data = json.loads(USER_CALIBRATIONS.read_text())
273
+ data[self.small_name] = result
274
+ USER_CALIBRATIONS.write_text(json.dumps(data, indent=2))
275
+
276
+ self.temperature = T
277
+ self._small.T = T
278
+ print(f"[greengate] T={T:.3f}, ECE {result['ece_before']:.4f} -> "
279
+ f"{result['ece_after']:.4f}, saved to {USER_CALIBRATIONS}")
280
+ return result
greengate/entropy.py ADDED
@@ -0,0 +1,36 @@
1
+ import torch
2
+ import torch.nn.functional as F
3
+
4
+
5
+ def shannon_entropy(logits: torch.Tensor) -> float:
6
+ """Compute Shannon entropy H(x) = -sum(P(x) * log2(P(x))) over token logits.
7
+
8
+ Args:
9
+ logits: Raw logits tensor of shape (vocab_size,) from a single generation step.
10
+
11
+ Returns:
12
+ Entropy in bits. Low = confident, high = uncertain.
13
+ """
14
+ probs = F.softmax(logits, dim=-1)
15
+ probs = probs.clamp(min=1e-9)
16
+ entropy = -(probs * probs.log2()).sum(dim=-1)
17
+ return entropy.item()
18
+
19
+
20
+ def mean_token_entropy(all_logits: list[torch.Tensor]) -> float:
21
+ """Average Shannon entropy across all generated tokens."""
22
+ if not all_logits:
23
+ return 0.0
24
+ entropies = [shannon_entropy(logits.squeeze()) for logits in all_logits]
25
+ return sum(entropies) / len(entropies)
26
+
27
+
28
+ def first_token_entropy(all_logits: list[torch.Tensor]) -> float:
29
+ """Shannon entropy of just the first generated token.
30
+
31
+ The first token is often the most informative signal for routing:
32
+ a confident model commits early, an uncertain model hedges immediately.
33
+ """
34
+ if not all_logits:
35
+ return 0.0
36
+ return shannon_entropy(all_logits[0].squeeze())
greengate/evaluator.py ADDED
@@ -0,0 +1,102 @@
1
+ """Multiple-choice evaluator for MMLU-style questions.
2
+
3
+ Instead of free-text generation, we do a SINGLE forward pass and read the
4
+ next-token logits restricted to the four choice letters (A/B/C/D). This gives:
5
+
6
+ - prediction: argmax over the four letter tokens
7
+ - choice entropy: Shannon entropy over the renormalised 4-way distribution
8
+ (0 bits = fully confident, 2 bits = maximally uncertain)
9
+ - energy/carbon: measured around the forward pass via CarbonProfiler
10
+
11
+ One forward pass per question per model keeps the evaluation cheap enough
12
+ for CPU smoke tests and free-tier GPUs.
13
+ """
14
+
15
+ import torch
16
+ import torch.nn.functional as F
17
+ from dataclasses import dataclass
18
+
19
+ from greengate.mmlu import MMLUQuestion, CHOICE_LETTERS
20
+ from greengate.profiler import CarbonProfiler
21
+
22
+
23
+ @dataclass
24
+ class ChoiceResult:
25
+ predicted_idx: int
26
+ correct: bool
27
+ entropy: float # bits, over the 4 choices (max = 2.0)
28
+ energy_joules: float
29
+ carbon_grams: float
30
+ choice_logits: list[float] | None = None # raw A/B/C/D logits (for calibration)
31
+
32
+
33
+ class ChoiceEvaluator:
34
+ """Wraps one causal LM for multiple-choice prediction with energy tracking."""
35
+
36
+ def __init__(self, model_name: str, device: str | None = None,
37
+ load_in_4bit: bool = False,
38
+ carbon_intensity: float = 475.0, pue: float = 1.2):
39
+ self.model_name = model_name
40
+ if device is None:
41
+ device = "cuda" if torch.cuda.is_available() else "cpu"
42
+ self.device = device
43
+
44
+ from transformers import AutoTokenizer, AutoModelForCausalLM
45
+
46
+ self.tokenizer = AutoTokenizer.from_pretrained(model_name)
47
+ if self.tokenizer.pad_token is None:
48
+ self.tokenizer.pad_token = self.tokenizer.eos_token
49
+
50
+ load_kwargs = {}
51
+ if device == "cuda":
52
+ load_kwargs["device_map"] = "auto"
53
+ if load_in_4bit:
54
+ from transformers import BitsAndBytesConfig
55
+ load_kwargs["quantization_config"] = BitsAndBytesConfig(
56
+ load_in_4bit=True, bnb_4bit_compute_dtype=torch.float16
57
+ )
58
+ else:
59
+ load_kwargs["dtype"] = torch.float16
60
+ else:
61
+ load_kwargs["dtype"] = torch.float32
62
+
63
+ self.model = AutoModelForCausalLM.from_pretrained(model_name, **load_kwargs)
64
+ if device == "cpu":
65
+ self.model = self.model.to("cpu")
66
+ self.model.eval()
67
+
68
+ self.profiler = CarbonProfiler(carbon_intensity=carbon_intensity, pue=pue)
69
+
70
+ # Token ids for " A", " B", " C", " D" (with leading space, as they
71
+ # follow "Answer:"). Fall back to bare letters if needed.
72
+ self.choice_token_ids = []
73
+ for letter in CHOICE_LETTERS:
74
+ ids = self.tokenizer.encode(" " + letter, add_special_tokens=False)
75
+ if len(ids) != 1:
76
+ ids = self.tokenizer.encode(letter, add_special_tokens=False)
77
+ self.choice_token_ids.append(ids[0])
78
+
79
+ @torch.no_grad()
80
+ def evaluate(self, q: MMLUQuestion) -> ChoiceResult:
81
+ prompt = q.to_prompt()
82
+ inputs = self.tokenizer(prompt, return_tensors="pt", truncation=True,
83
+ max_length=1024)
84
+ inputs = {k: v.to(self.model.device) for k, v in inputs.items()}
85
+
86
+ self.profiler.start()
87
+ logits = self.model(**inputs).logits[0, -1, :]
88
+ energy, carbon = self.profiler.stop()
89
+
90
+ choice_logits = logits[self.choice_token_ids]
91
+ probs = F.softmax(choice_logits, dim=-1).clamp(min=1e-9)
92
+ entropy = -(probs * probs.log2()).sum().item()
93
+ predicted_idx = int(probs.argmax().item())
94
+
95
+ return ChoiceResult(
96
+ predicted_idx=predicted_idx,
97
+ correct=(predicted_idx == q.answer_idx),
98
+ entropy=entropy,
99
+ energy_joules=energy,
100
+ carbon_grams=carbon,
101
+ choice_logits=[float(x) for x in choice_logits.tolist()],
102
+ )