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 +12 -0
- greengate/api_tier.py +153 -0
- greengate/budget.py +31 -0
- greengate/calibration.py +53 -0
- greengate/core.py +280 -0
- greengate/entropy.py +36 -0
- greengate/evaluator.py +102 -0
- greengate/mmlu.py +57 -0
- greengate/presets.json +17 -0
- greengate/profiler.py +154 -0
- greengate/router.py +152 -0
- greengate/semantic.py +62 -0
- greengate/sharegpt.py +73 -0
- greengate/textgen.py +109 -0
- greengate/visiongen.py +106 -0
- greengate/vqa.py +46 -0
- greengate-0.1.0.dist-info/METADATA +136 -0
- greengate-0.1.0.dist-info/RECORD +21 -0
- greengate-0.1.0.dist-info/WHEEL +5 -0
- greengate-0.1.0.dist-info/licenses/LICENSE +21 -0
- greengate-0.1.0.dist-info/top_level.txt +1 -0
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))
|
greengate/calibration.py
ADDED
|
@@ -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
|
+
)
|