anhtld commited on
Commit
b9869d2
·
verified ·
1 Parent(s): 10e4fc2

ctt metrics api

Browse files
Files changed (2) hide show
  1. workspace/cil/__init__.py +32 -0
  2. workspace/cil/metrics.py +367 -0
workspace/cil/__init__.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Canonical CIL research APIs.
2
+
3
+ This lightweight package is intentionally separate from the implementation-heavy
4
+ ``dovla_cil`` modules. It gives scripts, evaluator code, and paper artifacts a
5
+ stable place to import benchmark metrics from.
6
+ """
7
+
8
+ from cil.metrics import (
9
+ bootstrap_ci,
10
+ branch_causal_action_regret,
11
+ car_decomposition,
12
+ macro_micro_summary,
13
+ negative_near_at_threshold,
14
+ pairwise_causal_dominance_ece,
15
+ positive_tangent_recall_at_k,
16
+ positives_closer_than_negatives,
17
+ selector_regret_at_k,
18
+ support_gap,
19
+ )
20
+
21
+ __all__ = [
22
+ "bootstrap_ci",
23
+ "branch_causal_action_regret",
24
+ "car_decomposition",
25
+ "macro_micro_summary",
26
+ "negative_near_at_threshold",
27
+ "pairwise_causal_dominance_ece",
28
+ "positive_tangent_recall_at_k",
29
+ "positives_closer_than_negatives",
30
+ "selector_regret_at_k",
31
+ "support_gap",
32
+ ]
workspace/cil/metrics.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import random
5
+ from collections import defaultdict
6
+ from collections.abc import Iterable, Mapping, Sequence
7
+ from typing import Any
8
+
9
+
10
+ Number = int | float
11
+ Vector = Sequence[Number]
12
+ Matrix = Sequence[Vector]
13
+
14
+
15
+ def branch_causal_action_regret(best_measured_branch: Number, selected_branch: Number) -> float:
16
+ """Regret to the best measured branch in a same-state action chart.
17
+
18
+ The metric is clipped at zero so a generated action that exceeds the hidden chart
19
+ oracle does not produce negative regret.
20
+ """
21
+
22
+ return max(0.0, float(best_measured_branch) - float(selected_branch))
23
+
24
+
25
+ def positive_tangent_recall_at_k(
26
+ generated_utilities: Sequence[Number],
27
+ base_utility: Number,
28
+ *,
29
+ epsilon: float = 0.0,
30
+ k: int | None = None,
31
+ ) -> float:
32
+ """Return 1.0 if any generated proposal improves over the base action."""
33
+
34
+ prefix = _prefix(generated_utilities, k)
35
+ threshold = float(base_utility) + float(epsilon)
36
+ return 1.0 if any(float(value) > threshold for value in prefix) else 0.0
37
+
38
+
39
+ def selector_regret_at_k(
40
+ generated_utilities: Sequence[Number],
41
+ *,
42
+ selected_index: int = 0,
43
+ k: int | None = None,
44
+ ) -> float:
45
+ """Selector regret inside the generated proposal prefix."""
46
+
47
+ prefix = _prefix(generated_utilities, k)
48
+ if not prefix:
49
+ return 0.0
50
+ if selected_index < 0 or selected_index >= len(prefix):
51
+ raise IndexError("selected_index must refer to an element inside the K-prefix")
52
+ return branch_causal_action_regret(max(float(value) for value in prefix), prefix[selected_index])
53
+
54
+
55
+ def support_gap(best_hidden_chart: Number, best_generated_set: Number) -> float:
56
+ """Proposal-support gap to a hidden same-state chart oracle."""
57
+
58
+ return max(0.0, float(best_hidden_chart) - float(best_generated_set))
59
+
60
+
61
+ def car_decomposition(
62
+ *,
63
+ best_hidden_chart: Number,
64
+ best_generated_set: Number,
65
+ selected: Number,
66
+ base: Number | None = None,
67
+ ) -> dict[str, float | None]:
68
+ """Decompose total CAR into support gap and selector gap.
69
+
70
+ ``best_hidden_chart`` is the hidden same-state oracle. ``best_generated_set`` is
71
+ the oracle over deployment-clean generated candidates. ``selected`` is the action
72
+ actually executed by the method.
73
+ """
74
+
75
+ support = support_gap(best_hidden_chart, best_generated_set)
76
+ selector = branch_causal_action_regret(best_generated_set, selected)
77
+ payload: dict[str, float | None] = {
78
+ "base": None if base is None else float(base),
79
+ "selected": float(selected),
80
+ "best_generated_set": float(best_generated_set),
81
+ "best_hidden_chart": float(best_hidden_chart),
82
+ "support_gap": support,
83
+ "selector_gap": selector,
84
+ "total_car": branch_causal_action_regret(best_hidden_chart, selected),
85
+ "support_fraction_of_total": None,
86
+ "selector_fraction_of_total": None,
87
+ "clean_gain": None,
88
+ "closed_fraction_of_base_to_chart_gap": None,
89
+ }
90
+ total = support + selector
91
+ if total > 0.0:
92
+ payload["support_fraction_of_total"] = support / total
93
+ payload["selector_fraction_of_total"] = selector / total
94
+ if base is not None:
95
+ base_value = float(base)
96
+ clean_gain = float(selected) - base_value
97
+ chart_gap = float(best_hidden_chart) - base_value
98
+ payload["clean_gain"] = clean_gain
99
+ payload["closed_fraction_of_base_to_chart_gap"] = (
100
+ clean_gain / chart_gap if chart_gap > 0.0 else None
101
+ )
102
+ return payload
103
+
104
+
105
+ def negative_near_at_threshold(
106
+ generated_tangents: Matrix,
107
+ negative_tangents: Matrix,
108
+ *,
109
+ threshold: float,
110
+ k: int | None = None,
111
+ ) -> float:
112
+ """Fraction of generated tangents within ``threshold`` of any negative tangent."""
113
+
114
+ if threshold < 0:
115
+ raise ValueError("threshold must be non-negative")
116
+ generated = _prefix(generated_tangents, k)
117
+ if not generated or not negative_tangents:
118
+ return 0.0
119
+ count = 0
120
+ for tangent in generated:
121
+ nearest_negative = min(_rms_l2(tangent, negative) for negative in negative_tangents)
122
+ if nearest_negative <= threshold:
123
+ count += 1
124
+ return count / len(generated)
125
+
126
+
127
+ def positives_closer_than_negatives(
128
+ generated_tangents: Matrix,
129
+ positive_tangents: Matrix,
130
+ negative_tangents: Matrix,
131
+ *,
132
+ k: int | None = None,
133
+ ) -> float | None:
134
+ """Fraction of generated tangents closer to positive support than negative support."""
135
+
136
+ generated = _prefix(generated_tangents, k)
137
+ if not generated or not positive_tangents or not negative_tangents:
138
+ return None
139
+ count = 0
140
+ for tangent in generated:
141
+ positive_distance = min(_rms_l2(tangent, positive) for positive in positive_tangents)
142
+ negative_distance = min(_rms_l2(tangent, negative) for negative in negative_tangents)
143
+ if positive_distance < negative_distance:
144
+ count += 1
145
+ return count / len(generated)
146
+
147
+
148
+ def pairwise_causal_dominance_ece(
149
+ predicted_scores: Sequence[Number],
150
+ utilities: Sequence[Number],
151
+ *,
152
+ n_bins: int = 10,
153
+ temperature: float = 1.0,
154
+ min_utility_margin: float = 0.0,
155
+ ) -> dict[str, Any]:
156
+ """Expected calibration error for pairwise causal dominance predictions."""
157
+
158
+ if len(predicted_scores) != len(utilities):
159
+ raise ValueError("predicted_scores and utilities must have the same length")
160
+ if n_bins <= 0:
161
+ raise ValueError("n_bins must be positive")
162
+ if temperature <= 0.0:
163
+ raise ValueError("temperature must be positive")
164
+
165
+ bins = [
166
+ {
167
+ "count": 0,
168
+ "accuracy_sum": 0.0,
169
+ "confidence_sum": 0.0,
170
+ "lower": index / n_bins,
171
+ "upper": (index + 1) / n_bins,
172
+ }
173
+ for index in range(n_bins)
174
+ ]
175
+ total = 0
176
+ correct_sum = 0.0
177
+ confidence_sum = 0.0
178
+ for left in range(len(predicted_scores)):
179
+ for right in range(left + 1, len(predicted_scores)):
180
+ utility_margin = float(utilities[left]) - float(utilities[right])
181
+ if abs(utility_margin) <= min_utility_margin:
182
+ continue
183
+ score_margin = (float(predicted_scores[left]) - float(predicted_scores[right])) / (
184
+ float(temperature)
185
+ )
186
+ probability_left_dominates = _sigmoid(score_margin)
187
+ confidence = max(probability_left_dominates, 1.0 - probability_left_dominates)
188
+ prediction_left_dominates = probability_left_dominates >= 0.5
189
+ truth_left_dominates = utility_margin > 0.0
190
+ correct = float(prediction_left_dominates == truth_left_dominates)
191
+ bin_index = min(n_bins - 1, int(confidence * n_bins))
192
+ bucket = bins[bin_index]
193
+ bucket["count"] += 1
194
+ bucket["accuracy_sum"] += correct
195
+ bucket["confidence_sum"] += confidence
196
+ total += 1
197
+ correct_sum += correct
198
+ confidence_sum += confidence
199
+
200
+ ece = 0.0
201
+ rendered_bins: list[dict[str, float | int]] = []
202
+ for bucket in bins:
203
+ count = int(bucket["count"])
204
+ accuracy = bucket["accuracy_sum"] / count if count else 0.0
205
+ confidence = bucket["confidence_sum"] / count if count else 0.0
206
+ if total:
207
+ ece += (count / total) * abs(accuracy - confidence)
208
+ rendered_bins.append(
209
+ {
210
+ "lower": float(bucket["lower"]),
211
+ "upper": float(bucket["upper"]),
212
+ "count": count,
213
+ "accuracy": accuracy,
214
+ "confidence": confidence,
215
+ "abs_gap": abs(accuracy - confidence),
216
+ }
217
+ )
218
+ return {
219
+ "ece": ece,
220
+ "num_pairs": total,
221
+ "accuracy": correct_sum / total if total else None,
222
+ "mean_confidence": confidence_sum / total if total else None,
223
+ "bins": rendered_bins,
224
+ }
225
+
226
+
227
+ def bootstrap_ci(
228
+ values: Iterable[Number],
229
+ *,
230
+ num_samples: int = 1000,
231
+ confidence: float = 0.95,
232
+ seed: int = 0,
233
+ ) -> dict[str, float | int | None]:
234
+ """Bootstrap confidence interval for the mean."""
235
+
236
+ clean = [float(value) for value in values if math.isfinite(float(value))]
237
+ if num_samples <= 0:
238
+ raise ValueError("num_samples must be positive")
239
+ if not 0.0 < confidence < 1.0:
240
+ raise ValueError("confidence must be in (0, 1)")
241
+ if not clean:
242
+ return {
243
+ "n": 0,
244
+ "mean": None,
245
+ "low": None,
246
+ "high": None,
247
+ "confidence": confidence,
248
+ "num_samples": num_samples,
249
+ }
250
+
251
+ rng = random.Random(seed)
252
+ means = []
253
+ for _ in range(num_samples):
254
+ sample = [clean[rng.randrange(len(clean))] for _ in range(len(clean))]
255
+ means.append(sum(sample) / len(sample))
256
+ means.sort()
257
+ alpha = (1.0 - confidence) / 2.0
258
+ low_index = min(len(means) - 1, max(0, int(math.floor(alpha * len(means)))))
259
+ high_index = min(len(means) - 1, max(0, int(math.ceil((1.0 - alpha) * len(means))) - 1))
260
+ return {
261
+ "n": len(clean),
262
+ "mean": sum(clean) / len(clean),
263
+ "low": means[low_index],
264
+ "high": means[high_index],
265
+ "confidence": confidence,
266
+ "num_samples": num_samples,
267
+ }
268
+
269
+
270
+ def macro_micro_summary(
271
+ rows: Iterable[Mapping[str, Any]],
272
+ value_key: str,
273
+ *,
274
+ task_key: str = "task_id",
275
+ seed_key: str = "seed",
276
+ bootstrap_samples: int = 1000,
277
+ confidence: float = 0.95,
278
+ seed: int = 0,
279
+ ) -> dict[str, Any]:
280
+ """Summarize metric rows with micro, task-macro, and seed-macro means."""
281
+
282
+ materialized = list(rows)
283
+ values = [_maybe_float(row.get(value_key)) for row in materialized]
284
+ values = [value for value in values if value is not None]
285
+ by_task: dict[str, list[float]] = defaultdict(list)
286
+ by_seed: dict[str, list[float]] = defaultdict(list)
287
+ for row in materialized:
288
+ value = _maybe_float(row.get(value_key))
289
+ if value is None:
290
+ continue
291
+ by_task[str(row.get(task_key, "unknown"))].append(value)
292
+ by_seed[str(row.get(seed_key, "unknown"))].append(value)
293
+
294
+ task_means = {key: _mean(items) for key, items in sorted(by_task.items())}
295
+ seed_means = {key: _mean(items) for key, items in sorted(by_seed.items())}
296
+ task_macro_values = [value for value in task_means.values() if value is not None]
297
+ seed_macro_values = [value for value in seed_means.values() if value is not None]
298
+ return {
299
+ "metric": value_key,
300
+ "n": len(values),
301
+ "micro": bootstrap_ci(
302
+ values,
303
+ num_samples=bootstrap_samples,
304
+ confidence=confidence,
305
+ seed=seed,
306
+ ),
307
+ "macro_by_task": {
308
+ "mean": _mean(task_macro_values),
309
+ "per_task": task_means,
310
+ "ci": bootstrap_ci(
311
+ task_macro_values,
312
+ num_samples=bootstrap_samples,
313
+ confidence=confidence,
314
+ seed=seed + 1,
315
+ ),
316
+ },
317
+ "macro_by_seed": {
318
+ "mean": _mean(seed_macro_values),
319
+ "per_seed": seed_means,
320
+ "ci": bootstrap_ci(
321
+ seed_macro_values,
322
+ num_samples=bootstrap_samples,
323
+ confidence=confidence,
324
+ seed=seed + 2,
325
+ ),
326
+ },
327
+ }
328
+
329
+
330
+ def _prefix(values: Sequence[Any], k: int | None) -> list[Any]:
331
+ if k is None:
332
+ return list(values)
333
+ if k <= 0:
334
+ raise ValueError("k must be positive when provided")
335
+ return list(values[:k])
336
+
337
+
338
+ def _rms_l2(left: Vector, right: Vector) -> float:
339
+ if len(left) != len(right):
340
+ raise ValueError("tangent vectors must have the same length")
341
+ if not left:
342
+ return 0.0
343
+ squared = [(float(a) - float(b)) ** 2 for a, b in zip(left, right, strict=True)]
344
+ return math.sqrt(sum(squared) / len(squared))
345
+
346
+
347
+ def _sigmoid(value: float) -> float:
348
+ if value >= 0.0:
349
+ z = math.exp(-value)
350
+ return 1.0 / (1.0 + z)
351
+ z = math.exp(value)
352
+ return z / (1.0 + z)
353
+
354
+
355
+ def _maybe_float(value: Any) -> float | None:
356
+ if value is None:
357
+ return None
358
+ try:
359
+ numeric = float(value)
360
+ except (TypeError, ValueError):
361
+ return None
362
+ return numeric if math.isfinite(numeric) else None
363
+
364
+
365
+ def _mean(values: Iterable[float]) -> float | None:
366
+ clean = [float(value) for value in values if math.isfinite(float(value))]
367
+ return sum(clean) / len(clean) if clean else None