File size: 10,306 Bytes
9762da0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
"""Hierarchical gatekeeping multiple-testing controller.

Four levels of hypotheses, each with its own alpha budget (budgets sum to the
family alpha, so the union bound gives family-wise control across levels):

    L1  overall population delta                (single test)
    L2  per-class deltas                        (e-BH within level)
    L3  per class x condition strata            (e-BH within level)
    L4  difficulty bands (easy/medium/hard)     (e-BH within level)

Within a level, discoveries are controlled with e-BH (Benjamini-Hochberg on
e-values, Wang & Ramdas 2022): sort e-values descending and reject the k
hypotheses with e_(k) >= M / (k * alpha_level). e-BH is valid under ARBITRARY
dependence — essential here because the levels aggregate the same units and
the strata share scenes — and composes cleanly with optional stopping because
an e-process value at any stopping time is a valid e-value.

Safety-critical strata are PRE-REGISTERED PRIMARIES: each gets its own reserved
alpha and is tested individually, outside e-BH. They can therefore never be
masked by an improving overall metric or by many well-behaved sibling strata
(this is what makes the "overall improved, pedestrian-night regressed" case
detectable by construction).

Deltas are candidate-minus-baseline, so all rejection decisions are REGRESSION
claims; PASS is the separate equivalence-style claim per node (e_pass at the
node's pass alpha). Decisions are sticky: an anytime-valid rejection stays
rejected as more data arrive.
"""

from __future__ import annotations

from typing import Dict, List, Optional

import numpy as np

from sensorflow.seqeval import units as units_mod
from sensorflow.seqeval.sequential import (DECISION_INSUFFICIENT, DECISION_PASS,
                                           DECISION_REGRESSION, PairedSequentialTest)

LEVELS = {1: "overall", 2: "class", 3: "stratum", 4: "difficulty"}

# Default alpha budget shares (of the family alpha).
DEFAULT_ALPHA_SHARES = {
    "overall": 0.30,
    "safety": 0.30,     # split equally among pre-registered safety primaries
    "class": 0.15,
    "stratum": 0.15,
    "difficulty": 0.10,
}

MULTIPLE_TESTING_METHOD = ("hierarchical alpha budgeting (L1 overall / L2 class / "
                           "L3 stratum / L4 difficulty) with e-BH within levels; "
                           "safety primaries pre-registered at reserved alpha")


class Node:
    def __init__(self, key: str, level: int, delta: float, alpha_family: float,
                 cls_code: Optional[int] = None, cond_code: Optional[int] = None,
                 band: Optional[int] = None, weighted: bool = False,
                 safety_primary: bool = False):
        self.key = key
        self.level = level
        self.cls_code = cls_code
        self.cond_code = cond_code
        self.band = band
        self.weighted = weighted
        self.safety_primary = safety_primary
        self.test = PairedSequentialTest(delta=delta, alpha=alpha_family)
        self.suspect = False
        # clustering diagnostics (accumulated per batch)
        self._d_values: List[np.ndarray] = []
        self._d_clusters: List[np.ndarray] = []

    def select(self, batch: Dict[str, np.ndarray]) -> np.ndarray:
        mask = np.ones(batch["d"].size, dtype=bool)
        if self.cls_code is not None:
            mask &= batch["class"] == self.cls_code
        if self.cond_code is not None:
            mask &= batch["cond"] == self.cond_code
        if self.band is not None:
            mask &= batch["band"] == self.band
        return mask

    def update(self, batch: Dict[str, np.ndarray]) -> None:
        mask = self.select(batch)
        if not mask.any():
            return
        d = batch["d"][mask]
        containers = batch["container_id"][mask]
        w = batch["weight"][mask] if self.weighted else None
        means, _ = units_mod.cluster_units(d, containers, weights=w)
        self.test.update_clusters(means)
        self.test.record_objects(batch["b"][mask], batch["c"][mask])
        self._d_values.append(d)
        self._d_clusters.append(containers)

    def clustering(self) -> Dict:
        if not self._d_values:
            return {"n": 0, "n_clusters": 0, "icc": 0.0, "design_effect": 1.0,
                    "n_effective": 0.0, "mean_cluster_size": 0.0}
        return units_mod.cluster_summary(np.concatenate(self._d_values),
                                         np.concatenate(self._d_clusters))


def e_bh_reject(e_values: Dict[str, float], alpha: float) -> List[str]:
    """e-BH: reject the k hypotheses with largest e-values where
    e_(k) >= M / (k * alpha). Valid under arbitrary dependence."""
    if not e_values or alpha <= 0:
        return []
    items = sorted(e_values.items(), key=lambda kv: -kv[1])
    m = len(items)
    k_star = 0
    for k, (_, e) in enumerate(items, start=1):
        if e >= m / (k * alpha):
            k_star = k
    return [key for key, _ in items[:k_star]]


class HierarchyController:
    def __init__(self, strata: Dict[str, Dict], n_cond: int, delta: float,
                 alpha: float, alpha_pass: float,
                 alpha_shares: Optional[Dict[str, float]] = None,
                 band_names: Optional[List[str]] = None):
        self.alpha = float(alpha)
        self.alpha_pass = float(alpha_pass)
        self.shares = {**DEFAULT_ALPHA_SHARES, **(alpha_shares or {})}
        self.nodes: Dict[str, Node] = {}
        band_names = band_names or ["easy", "medium", "hard"]

        self.nodes["overall"] = Node("overall", 1, delta, alpha, weighted=True)
        classes = sorted({(int(s) // n_cond, v["class"]) for s, v in strata.items()})
        for code, name in classes:
            self.nodes[f"class:{name}"] = Node(f"class:{name}", 2, delta, alpha,
                                               cls_code=code, weighted=True)
        self.safety_keys: List[str] = []
        for s, v in strata.items():
            s = int(s)
            key = f"stratum:{v['key']}"
            node = Node(key, 3, delta, alpha, cls_code=s // n_cond,
                        cond_code=s % n_cond, safety_primary=v.get("safety_primary", False))
            self.nodes[key] = node
            if node.safety_primary:
                self.safety_keys.append(key)
        for b, name in enumerate(band_names):
            self.nodes[f"difficulty:{name}"] = Node(f"difficulty:{name}", 4, delta,
                                                    alpha, band=b, weighted=True)

    # ---- alpha allocation ------------------------------------------------

    def alpha_for(self, node: Node) -> float:
        a = self.alpha
        if node.safety_primary:
            return a * self.shares["safety"] / max(len(self.safety_keys), 1)
        if node.level == 1:
            return a * self.shares["overall"]
        if node.level == 2:
            return a * self.shares["class"]      # level budget, shared via e-BH
        if node.level == 3:
            return a * self.shares["stratum"]
        return a * self.shares["difficulty"]

    # ---- updates -----------------------------------------------------------

    def update_with_batch(self, batch: Dict[str, np.ndarray]) -> None:
        for node in self.nodes.values():
            node.update(batch)

    def evaluate(self, screening_e_threshold: float = 1.5) -> Dict[str, str]:
        """Apply per-level testing; return {node_key: decision}. Sticky."""
        # pre-registered primaries + single-hypothesis levels: individual tests
        for node in self.nodes.values():
            if node.safety_primary or node.level == 1:
                node.test.evaluate(alpha_reg=self.alpha_for(node),
                                   alpha_pass=self.alpha_pass)
        # e-BH within L2 / L3(non-safety) / L4
        for level, share_key in ((2, "class"), (3, "stratum"), (4, "difficulty")):
            group = {k: n for k, n in self.nodes.items()
                     if n.level == level and not n.safety_primary}
            undecided = {k: n.test.e_reg.e_value for k, n in group.items()
                         if n.test.decision == DECISION_INSUFFICIENT}
            already = {k: n.test.e_reg.e_value for k, n in group.items()
                       if n.test.decision == DECISION_REGRESSION}
            # include already-rejected e-values so e-BH stays monotone/sticky
            rejected = e_bh_reject({**undecided, **already},
                                   self.alpha * self.shares[share_key])
            for k in rejected:
                node = group[k]
                if node.test.decision == DECISION_INSUFFICIENT:
                    node.test.decision = DECISION_REGRESSION
                    node.test.decided_at_n = node.test.n_objects
            # PASS claims are per-node equivalence statements
            for k, n in group.items():
                if n.test.decision == DECISION_INSUFFICIENT \
                        and n.test.e_pass.e_value >= 1.0 / self.alpha_pass:
                    n.test.decision = DECISION_PASS
                    n.test.decided_at_n = n.test.n_objects
        # screening flags (soft evidence, drives escalation priority)
        for node in self.nodes.values():
            if node.test.e_reg.e_value >= screening_e_threshold:
                node.suspect = True
        return {k: n.test.decision for k, n in self.nodes.items()}

    def record_trajectories(self) -> None:
        for node in self.nodes.values():
            if node.test.n_objects > 0:
                node.test.record_trajectory_point()

    # ---- summaries -----------------------------------------------------

    def decisions(self) -> Dict[str, str]:
        return {k: n.test.decision for k, n in self.nodes.items()}

    def any_regression(self) -> bool:
        return any(n.test.decision == DECISION_REGRESSION for n in self.nodes.values())

    def required_pass(self) -> bool:
        """Overall + every safety primary must individually reach PASS."""
        required = [self.nodes["overall"]] + [self.nodes[k] for k in self.safety_keys]
        return all(n.test.decision == DECISION_PASS for n in required)

    def undecided_keys(self) -> List[str]:
        return [k for k, n in self.nodes.items()
                if n.test.n_objects > 0 and n.test.decision == DECISION_INSUFFICIENT]