graphroute 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.
- graphroute/__init__.py +1 -0
- graphroute/calibration.py +230 -0
- graphroute/cli.py +141 -0
- graphroute/config.py +323 -0
- graphroute/data.py +65 -0
- graphroute/experiment.py +244 -0
- graphroute/gnn.py +486 -0
- graphroute/graph.py +496 -0
- graphroute/losses.py +232 -0
- graphroute/models.py +62 -0
- graphroute/pool.py +644 -0
- graphroute/pool_cache.py +310 -0
- graphroute/run.py +474 -0
- graphroute/selection.py +138 -0
- graphroute/training.py +554 -0
- graphroute-0.1.0.dist-info/METADATA +221 -0
- graphroute-0.1.0.dist-info/RECORD +20 -0
- graphroute-0.1.0.dist-info/WHEEL +5 -0
- graphroute-0.1.0.dist-info/licenses/LICENSE +21 -0
- graphroute-0.1.0.dist-info/top_level.txt +1 -0
graphroute/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""GraphRoute: graph-based dynamic ensembling."""
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Post-hoc probability calibration for the base-classifier pool.
|
|
2
|
+
|
|
3
|
+
A calibrator is fit on one set of logits and applied to others:
|
|
4
|
+
|
|
5
|
+
calibrator = get_calibrator("ts-mix", num_classes=C).fit(logits, labels)
|
|
6
|
+
probs = calibrator.predict_proba(other_logits)
|
|
7
|
+
|
|
8
|
+
``logits`` is ``[N, C]`` and the result is ``[N, C]`` probabilities. The two
|
|
9
|
+
methods are selected by ``graph.calib_method``:
|
|
10
|
+
|
|
11
|
+
``ts-mix``
|
|
12
|
+
Temperature scaling. One scalar per classifier; rescales confidence without
|
|
13
|
+
reordering predictions.
|
|
14
|
+
|
|
15
|
+
``logistic``
|
|
16
|
+
Platt scaling for two classes, structured matrix scaling for more. Both can
|
|
17
|
+
reorder predictions.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
import torch
|
|
25
|
+
import torch.nn.functional as F
|
|
26
|
+
import torchmin
|
|
27
|
+
|
|
28
|
+
#: A log-probability below this is indistinguishable from log(0) in float32.
|
|
29
|
+
_LOG_TINY = float(np.log(np.finfo(np.float32).tiny))
|
|
30
|
+
|
|
31
|
+
#: The methods ``get_calibrator`` accepts.
|
|
32
|
+
METHODS = ("ts-mix", "logistic")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _log_of(probs: torch.Tensor) -> torch.Tensor:
|
|
36
|
+
"""Probabilities as logits, floored so a zero does not become ``-inf``."""
|
|
37
|
+
return torch.clamp(torch.log(probs.float()), min=_LOG_TINY)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _normalized(logits: torch.Tensor) -> torch.Tensor:
|
|
41
|
+
"""Logits as bounded log-probabilities.
|
|
42
|
+
|
|
43
|
+
Every estimator here is invariant to a per-row shift, so normalizing costs
|
|
44
|
+
nothing and buys a guarantee: an unnormalized logit can be arbitrarily
|
|
45
|
+
large, while a log-probability is bounded above by zero and floored below.
|
|
46
|
+
"""
|
|
47
|
+
return _log_of(torch.softmax(logits, dim=-1))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TemperatureScaler:
|
|
51
|
+
"""Temperature scaling, mixed with a uniform distribution.
|
|
52
|
+
|
|
53
|
+
The inverse temperature is a single scalar, fit by bisection on the
|
|
54
|
+
derivative of the mean cross-entropy -- which is monotone in that scalar,
|
|
55
|
+
so bisection is exact rather than a search that might not converge.
|
|
56
|
+
|
|
57
|
+
The fitted probabilities are then mixed with a uniform distribution at
|
|
58
|
+
weight ``1 / (N + 1)``. Without it a confident classifier produces exact
|
|
59
|
+
zeros, and log loss is infinite there. The weight vanishes as the
|
|
60
|
+
calibration set grows, so it costs nothing where it is not needed.
|
|
61
|
+
|
|
62
|
+
Reference:
|
|
63
|
+
Guo, Pleiss, Sun and Weinberger. On calibration of modern neural
|
|
64
|
+
networks. ICML 2017.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, steps: int = 30, log_lo: float = -16.0, log_hi: float = 16.0):
|
|
68
|
+
self.steps = steps
|
|
69
|
+
self.log_lo, self.log_hi = log_lo, log_hi
|
|
70
|
+
self.inv_temp_, self.n_fit_ = 1.0, 0
|
|
71
|
+
|
|
72
|
+
def _ce_derivative(self, inv_temp: float, logits: torch.Tensor,
|
|
73
|
+
labels: torch.Tensor) -> float:
|
|
74
|
+
"""d/d(inv_temp) of the mean cross-entropy at this inverse temperature."""
|
|
75
|
+
probs = torch.softmax(inv_temp * logits, dim=-1)
|
|
76
|
+
return (torch.mean(torch.sum(logits * probs, dim=-1))
|
|
77
|
+
- torch.mean(logits[torch.arange(logits.shape[0]), labels])).item()
|
|
78
|
+
|
|
79
|
+
def fit(self, logits: torch.Tensor, labels: torch.Tensor) -> "TemperatureScaler":
|
|
80
|
+
"""Fit the inverse temperature. Args: logits [N, C], labels [N]."""
|
|
81
|
+
lo, hi = self.log_lo, self.log_hi
|
|
82
|
+
for _ in range(self.steps): # bisect in log-space, so
|
|
83
|
+
mid = lo + 0.5 * (hi - lo) # the scalar stays positive
|
|
84
|
+
if self._ce_derivative(math.exp(mid), logits, labels) > 0:
|
|
85
|
+
hi = mid
|
|
86
|
+
else:
|
|
87
|
+
lo = mid
|
|
88
|
+
self.inv_temp_ = math.exp(0.5 * (lo + hi))
|
|
89
|
+
self.n_fit_ = logits.shape[0]
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def predict_proba(self, logits: torch.Tensor) -> torch.Tensor:
|
|
93
|
+
"""Calibrated probabilities [N, C]."""
|
|
94
|
+
probs = torch.softmax(self.inv_temp_ * logits, dim=-1)
|
|
95
|
+
weight = 1.0 / (self.n_fit_ + 1)
|
|
96
|
+
return (1.0 - weight) * probs + weight / probs.shape[-1]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class PlattScaler:
|
|
100
|
+
"""Platt scaling: ``sigmoid(b + w * logit)``, two classes only.
|
|
101
|
+
|
|
102
|
+
An affine map of the binary logit, fit by unpenalized logistic regression.
|
|
103
|
+
Unlike temperature scaling the intercept lets it shift the decision
|
|
104
|
+
threshold, which is what an imbalanced pool member usually needs.
|
|
105
|
+
|
|
106
|
+
Reference:
|
|
107
|
+
Platt. Probabilistic outputs for support vector machines. Advances in
|
|
108
|
+
Large Margin Classifiers, 1999.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, max_iter: int = 200):
|
|
112
|
+
self.max_iter = max_iter
|
|
113
|
+
self.bias_, self.weight_ = 0.0, 1.0
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _binary_logit(logits: torch.Tensor) -> torch.Tensor:
|
|
117
|
+
"""The single logit behind a two-column score, as [N]."""
|
|
118
|
+
return (logits[:, 1] - logits[:, 0]).float()
|
|
119
|
+
|
|
120
|
+
def fit(self, logits: torch.Tensor, labels: torch.Tensor) -> "PlattScaler":
|
|
121
|
+
"""Fit intercept and slope. Args: logits [N, 2], labels [N] in {0, 1}."""
|
|
122
|
+
if logits.shape[-1] != 2:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"Platt scaling is binary; got {logits.shape[-1]} classes. Use "
|
|
125
|
+
f"structured matrix scaling for more.")
|
|
126
|
+
x = self._binary_logit(logits)
|
|
127
|
+
y = labels.to(x.dtype)
|
|
128
|
+
|
|
129
|
+
def objective(params):
|
|
130
|
+
return F.binary_cross_entropy_with_logits(params[0] + params[1] * x, y)
|
|
131
|
+
|
|
132
|
+
result = torchmin.minimize(
|
|
133
|
+
objective, torch.zeros(2, dtype=x.dtype), method="bfgs",
|
|
134
|
+
options={"max_iter": self.max_iter})
|
|
135
|
+
self.bias_, self.weight_ = result.x[0].item(), result.x[1].item()
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def predict_proba(self, logits: torch.Tensor) -> torch.Tensor:
|
|
139
|
+
"""Calibrated probabilities [N, 2]."""
|
|
140
|
+
p = torch.sigmoid(self.bias_ + self.weight_ * self._binary_logit(logits))
|
|
141
|
+
return torch.stack([1.0 - p, p], dim=1)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class StructuredMatrixScaler:
|
|
145
|
+
"""Structured matrix scaling: ``softmax((I + dW) x + b)`` on scaled logits.
|
|
146
|
+
|
|
147
|
+
Temperature scaling is applied first and its scalar held fixed, so ``dW``
|
|
148
|
+
and ``b`` only have to describe what a single temperature could not. The
|
|
149
|
+
penalty is separate for the intercept, the diagonal of ``dW`` and its
|
|
150
|
+
off-diagonal, each scaled by ``k**rho / n**tau`` -- a matrix has ``k**2``
|
|
151
|
+
parameters, so without a penalty that grows with the class count it fits
|
|
152
|
+
the calibration set rather than calibrating.
|
|
153
|
+
|
|
154
|
+
Reference:
|
|
155
|
+
Berta, Holzmuller, Jordan and Bach. Structured matrix scaling for
|
|
156
|
+
multi-class calibration. AISTATS 2026.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(self, rho: float = 1.0, tau: float = 1.0,
|
|
160
|
+
lambda_intercept: float = 1.0, lambda_diagonal: float = 1.0,
|
|
161
|
+
lambda_off_diagonal: float = 1.0):
|
|
162
|
+
self.rho, self.tau = rho, tau
|
|
163
|
+
self.lambda_intercept = lambda_intercept
|
|
164
|
+
self.lambda_diagonal = lambda_diagonal
|
|
165
|
+
self.lambda_off_diagonal = lambda_off_diagonal
|
|
166
|
+
|
|
167
|
+
def _scaled_log_probs(self, logits: torch.Tensor) -> torch.Tensor:
|
|
168
|
+
"""The temperature-scaled log-probabilities the matrix acts on.
|
|
169
|
+
|
|
170
|
+
The temperature step consumes and produces probabilities, so its output
|
|
171
|
+
goes back through ``_log_of`` -- not ``_normalized``, which would treat
|
|
172
|
+
those probabilities as logits and squash them a second time.
|
|
173
|
+
"""
|
|
174
|
+
return _log_of(self.temperature_.predict_proba(_normalized(logits)))
|
|
175
|
+
|
|
176
|
+
def fit(self, logits: torch.Tensor, labels: torch.Tensor) -> "StructuredMatrixScaler":
|
|
177
|
+
"""Fit the scaling matrix. Args: logits [N, C], labels [N]."""
|
|
178
|
+
n, k = logits.shape
|
|
179
|
+
self.temperature_ = TemperatureScaler().fit(_normalized(logits), labels)
|
|
180
|
+
x, y = self._scaled_log_probs(logits), labels.long()
|
|
181
|
+
|
|
182
|
+
reg_intercept = self.lambda_intercept * k ** self.rho / n ** self.tau
|
|
183
|
+
reg_diagonal = self.lambda_diagonal * k ** self.rho / n ** self.tau
|
|
184
|
+
reg_off_diagonal = (self.lambda_off_diagonal
|
|
185
|
+
* (k * (k - 1)) ** self.rho / n ** self.tau)
|
|
186
|
+
|
|
187
|
+
def objective(params):
|
|
188
|
+
delta, bias = params[:k * k].view(k, k), params[k * k:]
|
|
189
|
+
loss = F.cross_entropy(x + F.linear(x, delta, bias), y)
|
|
190
|
+
diagonal = delta.diagonal()
|
|
191
|
+
return (loss
|
|
192
|
+
+ reg_intercept * bias.pow(2).sum()
|
|
193
|
+
+ reg_diagonal * diagonal.pow(2).sum()
|
|
194
|
+
+ reg_off_diagonal * (delta.pow(2).sum() - diagonal.pow(2).sum()))
|
|
195
|
+
|
|
196
|
+
start = torch.zeros(k * (k + 1), dtype=x.dtype)
|
|
197
|
+
result = torchmin.minimize(
|
|
198
|
+
objective, start, method="l-bfgs" if start.numel() > 1000 else "bfgs")
|
|
199
|
+
|
|
200
|
+
# Carry the intercept as a final column and append a constant 1 to the
|
|
201
|
+
# inputs, so applying the calibrator is one matrix multiply.
|
|
202
|
+
matrix = torch.eye(k, dtype=x.dtype) + result.x[:k * k].view(k, k)
|
|
203
|
+
self.matrix_ = torch.hstack([matrix, result.x[k * k:].unsqueeze(1)])
|
|
204
|
+
return self
|
|
205
|
+
|
|
206
|
+
def predict_proba(self, logits: torch.Tensor) -> torch.Tensor:
|
|
207
|
+
"""Calibrated probabilities [N, C]."""
|
|
208
|
+
x = self._scaled_log_probs(logits)
|
|
209
|
+
x = torch.hstack([x, torch.ones(len(x), 1, dtype=x.dtype)])
|
|
210
|
+
return torch.softmax(x @ self.matrix_.T, dim=-1)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def get_calibrator(method: str = "ts-mix", num_classes: int = 2):
|
|
214
|
+
"""Build an unfitted calibrator.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
method: One of ``METHODS``.
|
|
218
|
+
num_classes: Decides the ``logistic`` estimator -- Platt scaling is
|
|
219
|
+
defined for two classes and structured matrix scaling generalizes
|
|
220
|
+
it, so the class count selects rather than the caller.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
A calibrator with ``fit(logits, labels)`` and ``predict_proba(logits)``.
|
|
224
|
+
"""
|
|
225
|
+
if method == "ts-mix":
|
|
226
|
+
return TemperatureScaler()
|
|
227
|
+
if method == "logistic":
|
|
228
|
+
return PlattScaler() if num_classes == 2 else StructuredMatrixScaler()
|
|
229
|
+
raise ValueError(f'Unknown calibration method "{method}". '
|
|
230
|
+
f'Known: {", ".join(METHODS)}.')
|
graphroute/cli.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Terminal overrides generated from GraphRoute's Pydantic configuration."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
from types import UnionType
|
|
6
|
+
from typing import Literal, Union, get_args, get_origin
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ValidationError
|
|
9
|
+
|
|
10
|
+
from graphroute.config import BaseConfig, GNNConfig, GraphConfig, GraphRouteConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_bool(value: str) -> bool:
|
|
14
|
+
"""Convert a terminal value such as ``true`` or ``false`` to a Boolean."""
|
|
15
|
+
normalized = value.lower()
|
|
16
|
+
if normalized in {"true", "yes", "1"}:
|
|
17
|
+
return True
|
|
18
|
+
if normalized in {"false", "no", "0"}:
|
|
19
|
+
return False
|
|
20
|
+
raise argparse.ArgumentTypeError(
|
|
21
|
+
f"Expected true or false, received {value!r}.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _remove_optional(annotation):
|
|
25
|
+
"""Return ``T`` when a field is annotated as ``Optional[T]``."""
|
|
26
|
+
origin = get_origin(annotation)
|
|
27
|
+
if origin in {Union, UnionType}:
|
|
28
|
+
members = [member for member in get_args(annotation)
|
|
29
|
+
if member is not type(None)]
|
|
30
|
+
if len(members) == 1:
|
|
31
|
+
return members[0]
|
|
32
|
+
return annotation
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _argument_settings(annotation) -> dict:
|
|
36
|
+
"""Derive argparse settings from a Pydantic field annotation."""
|
|
37
|
+
annotation = _remove_optional(annotation)
|
|
38
|
+
origin = get_origin(annotation)
|
|
39
|
+
|
|
40
|
+
if origin is Literal:
|
|
41
|
+
choices = list(get_args(annotation))
|
|
42
|
+
return {"choices": choices, "type": type(choices[0])}
|
|
43
|
+
if origin is list:
|
|
44
|
+
return {"nargs": "+", "type": _remove_optional(get_args(annotation)[0])}
|
|
45
|
+
if annotation is bool:
|
|
46
|
+
return {"type": _parse_bool}
|
|
47
|
+
if annotation in {str, int, float}:
|
|
48
|
+
return {"type": annotation}
|
|
49
|
+
raise TypeError(
|
|
50
|
+
f"Cannot create a terminal flag for annotation {annotation!r}.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _field_help(field) -> str:
|
|
54
|
+
"""Build help text from the description and default in config.py."""
|
|
55
|
+
parts = []
|
|
56
|
+
if field.description:
|
|
57
|
+
parts.append(field.description)
|
|
58
|
+
if field.is_required():
|
|
59
|
+
parts.append("Required.")
|
|
60
|
+
else:
|
|
61
|
+
default = field.get_default(call_default_factory=True)
|
|
62
|
+
parts.append(f"Default: {default!r}.")
|
|
63
|
+
return " ".join(parts)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _add_fields(
|
|
67
|
+
argument_group: argparse._ArgumentGroup,
|
|
68
|
+
model: type[BaseModel],
|
|
69
|
+
*,
|
|
70
|
+
prefix: str = "",
|
|
71
|
+
excluded: set[str] | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Create terminal flags from one Pydantic configuration model."""
|
|
74
|
+
excluded = excluded or set()
|
|
75
|
+
for field_name, field in model.model_fields.items():
|
|
76
|
+
if field_name in excluded:
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
path = f"{prefix}_{field_name}" if prefix else field_name
|
|
80
|
+
flag = f"--{path.replace('_', '-')}"
|
|
81
|
+
destination = f"{prefix}__{field_name}" if prefix else field_name
|
|
82
|
+
settings = _argument_settings(field.annotation)
|
|
83
|
+
if "choices" not in settings:
|
|
84
|
+
settings["metavar"] = field_name.upper()
|
|
85
|
+
argument_group.add_argument(
|
|
86
|
+
flag,
|
|
87
|
+
dest=destination,
|
|
88
|
+
default=argparse.SUPPRESS,
|
|
89
|
+
help=_field_help(field),
|
|
90
|
+
**settings,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
95
|
+
"""Build terminal flags from the GraphRoute Pydantic configuration."""
|
|
96
|
+
parser = argparse.ArgumentParser(
|
|
97
|
+
description="Run a GraphRoute experiment.")
|
|
98
|
+
|
|
99
|
+
general = parser.add_argument_group("General")
|
|
100
|
+
_add_fields(
|
|
101
|
+
general, GraphRouteConfig, excluded={"base", "graph", "gnn"})
|
|
102
|
+
_add_fields(
|
|
103
|
+
parser.add_argument_group("Base-model pool"), BaseConfig, prefix="base")
|
|
104
|
+
_add_fields(
|
|
105
|
+
parser.add_argument_group("Graph construction"), GraphConfig,
|
|
106
|
+
prefix="graph")
|
|
107
|
+
_add_fields(
|
|
108
|
+
parser.add_argument_group("GNN training and dynamic selection"),
|
|
109
|
+
GNNConfig, prefix="gnn")
|
|
110
|
+
return parser
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _set_nested_value(values: dict, destination: str, value) -> None:
|
|
114
|
+
"""Apply a parsed terminal value to its nested configuration group."""
|
|
115
|
+
path = destination.split("__")
|
|
116
|
+
current = values
|
|
117
|
+
for part in path[:-1]:
|
|
118
|
+
current = current.setdefault(part, {})
|
|
119
|
+
current[path[-1]] = value
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def config_from_args(
|
|
123
|
+
argv: list[str] | None = None,
|
|
124
|
+
*,
|
|
125
|
+
base: GraphRouteConfig | None = None,
|
|
126
|
+
) -> GraphRouteConfig:
|
|
127
|
+
"""Apply explicitly supplied terminal overrides to a configuration."""
|
|
128
|
+
parser = build_parser()
|
|
129
|
+
parsed = parser.parse_args(argv)
|
|
130
|
+
# Pydantic supplies every value that neither the script nor the terminal set.
|
|
131
|
+
values = {} if base is None else base.model_dump(exclude_unset=True)
|
|
132
|
+
for destination, value in vars(parsed).items():
|
|
133
|
+
_set_nested_value(values, destination, value)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
return GraphRouteConfig.model_validate(values)
|
|
137
|
+
except ValidationError as error:
|
|
138
|
+
parser.error(str(error))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
__all__ = ["build_parser", "config_from_args"]
|