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 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"]