a11ydebt 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.
- a11ydebt/__init__.py +3 -0
- a11ydebt/androidcontrol.py +268 -0
- a11ydebt/bootstrap.py +230 -0
- a11ydebt/cli.py +316 -0
- a11ydebt/counts.py +80 -0
- a11ydebt/coverage.py +271 -0
- a11ydebt/kaggle_outputs.py +81 -0
- a11ydebt/report.py +417 -0
- a11ydebt/rico.py +204 -0
- a11ydebt/taxonomy.py +312 -0
- a11ydebt/tfrecord.py +413 -0
- a11ydebt-0.1.0.dist-info/METADATA +196 -0
- a11ydebt-0.1.0.dist-info/RECORD +17 -0
- a11ydebt-0.1.0.dist-info/WHEEL +4 -0
- a11ydebt-0.1.0.dist-info/entry_points.txt +2 -0
- a11ydebt-0.1.0.dist-info/licenses/LICENSE +202 -0
- a11ydebt-0.1.0.dist-info/licenses/NOTICE +47 -0
a11ydebt/__init__.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""AndroidControl loader: TFRecord shards to episodes, one screen per step, flags per node.
|
|
2
|
+
|
|
3
|
+
Ports the parse logic of ``phase2_processing/02_parse_androidcontrol.py`` and
|
|
4
|
+
``src/a11ydebt/apps.py`` from accessibility-debt-agents (commit 97c0378, MIT; see NOTICE), and
|
|
5
|
+
keeps the rules that decide the counts unchanged:
|
|
6
|
+
|
|
7
|
+
* A screen is one step of an episode, ``f"{episode_id}_{step_index}"``. Its nodes are every node
|
|
8
|
+
of every window in that step's AndroidAccessibilityForest, windows in forest order.
|
|
9
|
+
* A forest that fails to decode becomes an empty screen, and an episode that fails to decode is
|
|
10
|
+
recorded as a failure and left out. A truncated shard yields every record it could read and
|
|
11
|
+
sets ``ShardReader.truncated``.
|
|
12
|
+
* The codebook is ``a11ydebt.taxonomy``, applied exactly as for Rico. Roles come from the class name
|
|
13
|
+
only, ``labelled`` means a non-empty content description, and ``included`` means visible to the
|
|
14
|
+
user with positive area. The P01 ``interactive_image`` flag uses ``is_focusable``.
|
|
15
|
+
* An episode's app is the most frequent package among its nodes' resource ids
|
|
16
|
+
(``<package>:id/<name>``), ignoring platform packages, with ties broken alphabetically. An
|
|
17
|
+
episode with no usable resource id gets app "".
|
|
18
|
+
|
|
19
|
+
Standard library only, so the Kaggle kernel can bundle it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
from collections import Counter
|
|
27
|
+
from collections.abc import Iterator, Mapping
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from a11ydebt import taxonomy as tx
|
|
33
|
+
from a11ydebt.counts import POPULATIONS
|
|
34
|
+
from a11ydebt.tfrecord import TruncatedShardError, iter_records, parse_example, parse_forest
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"SYSTEM_PACKAGES",
|
|
38
|
+
"AcEpisode",
|
|
39
|
+
"AcNode",
|
|
40
|
+
"AcScreen",
|
|
41
|
+
"ShardReader",
|
|
42
|
+
"episode_app",
|
|
43
|
+
"load_splits",
|
|
44
|
+
"parse_episode",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
#: Platform packages that appear on every device; never an episode's app (AISSA config.yaml).
|
|
48
|
+
SYSTEM_PACKAGES: frozenset[str] = frozenset(
|
|
49
|
+
{
|
|
50
|
+
"android",
|
|
51
|
+
"com.android.systemui",
|
|
52
|
+
"com.android.launcher3",
|
|
53
|
+
"com.android.intentresolver",
|
|
54
|
+
"com.android.permissioncontroller",
|
|
55
|
+
"com.android.packageinstaller",
|
|
56
|
+
"com.android.printspooler",
|
|
57
|
+
"com.android.bips",
|
|
58
|
+
"com.android.htmlviewer",
|
|
59
|
+
"com.google.android.inputmethod.latin",
|
|
60
|
+
"com.google.android.apps.nexuslauncher",
|
|
61
|
+
"com.google.android.permissioncontroller",
|
|
62
|
+
"com.google.android.packageinstaller",
|
|
63
|
+
"com.google.android.gms",
|
|
64
|
+
"com.google.android.ext.services",
|
|
65
|
+
"com.google.android.settings.intelligence",
|
|
66
|
+
"com.google.android.tts",
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
_PACKAGE = re.compile(r"^([A-Za-z0-9_.]+):id/")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class AcNode:
|
|
74
|
+
"""One accessibility node with the codebook applied. ``node_uid`` is the window-local id."""
|
|
75
|
+
|
|
76
|
+
window_index: int
|
|
77
|
+
window_layer: int
|
|
78
|
+
node_uid: int
|
|
79
|
+
depth: int
|
|
80
|
+
class_name: str
|
|
81
|
+
resource_id: str
|
|
82
|
+
content_desc: str # cleaned: "" when absent or "null"
|
|
83
|
+
text: str # cleaned the same way
|
|
84
|
+
hint_text: str
|
|
85
|
+
clickable: bool
|
|
86
|
+
long_clickable: bool
|
|
87
|
+
focusable: bool
|
|
88
|
+
enabled: bool
|
|
89
|
+
visible_to_user: bool
|
|
90
|
+
bounds: tuple[int, int, int, int]
|
|
91
|
+
role: str
|
|
92
|
+
included: bool
|
|
93
|
+
needs_label: bool
|
|
94
|
+
icon_like: bool
|
|
95
|
+
interactive_image: bool
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def labelled(self) -> bool:
|
|
99
|
+
return bool(self.content_desc)
|
|
100
|
+
|
|
101
|
+
def in_population(self, population: str) -> bool:
|
|
102
|
+
if population not in POPULATIONS:
|
|
103
|
+
raise ValueError(f"unknown population {population!r}; choose from {POPULATIONS}")
|
|
104
|
+
return self.included and bool(getattr(self, population))
|
|
105
|
+
|
|
106
|
+
def flagged(self, population: str = "needs_label") -> bool:
|
|
107
|
+
return self.in_population(population) and not self.labelled
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class AcScreen:
|
|
112
|
+
screen_id: str
|
|
113
|
+
app: str
|
|
114
|
+
episode_id: int
|
|
115
|
+
step_index: int
|
|
116
|
+
width: int
|
|
117
|
+
height: int
|
|
118
|
+
nodes: tuple[AcNode, ...]
|
|
119
|
+
|
|
120
|
+
def flagged(self, population: str = "needs_label") -> list[AcNode]:
|
|
121
|
+
return [node for node in self.nodes if node.flagged(population)]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True)
|
|
125
|
+
class AcEpisode:
|
|
126
|
+
episode_id: int
|
|
127
|
+
split: str
|
|
128
|
+
shard: str
|
|
129
|
+
goal: str
|
|
130
|
+
app: str
|
|
131
|
+
n_actions: int
|
|
132
|
+
screens: tuple[AcScreen, ...]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _make_node(node: Any, window_index: int, window_layer: int) -> AcNode:
|
|
136
|
+
class_name = node.class_name
|
|
137
|
+
text = tx.clean_text(node.text)
|
|
138
|
+
role = tx.classify_role(class_name)
|
|
139
|
+
return AcNode(
|
|
140
|
+
window_index=window_index,
|
|
141
|
+
window_layer=window_layer,
|
|
142
|
+
node_uid=node.unique_id,
|
|
143
|
+
depth=node.depth,
|
|
144
|
+
class_name=class_name,
|
|
145
|
+
resource_id=node.view_id_resource_name,
|
|
146
|
+
content_desc=tx.clean_text(node.content_description),
|
|
147
|
+
text=text,
|
|
148
|
+
hint_text=node.hint_text,
|
|
149
|
+
clickable=node.is_clickable,
|
|
150
|
+
long_clickable=node.is_long_clickable,
|
|
151
|
+
focusable=node.is_focusable,
|
|
152
|
+
enabled=node.is_enabled,
|
|
153
|
+
visible_to_user=node.is_visible_to_user,
|
|
154
|
+
bounds=(node.left, node.top, node.right, node.bottom),
|
|
155
|
+
role=role,
|
|
156
|
+
included=tx.in_analysis_population(
|
|
157
|
+
node.is_visible_to_user, node.left, node.top, node.right, node.bottom, node.is_enabled
|
|
158
|
+
),
|
|
159
|
+
needs_label=tx.needs_label(role, node.is_clickable, node.is_long_clickable, text),
|
|
160
|
+
icon_like=tx.is_icon_like(role, text, node.is_clickable, node.is_long_clickable),
|
|
161
|
+
interactive_image=tx.is_interactive_image(class_name, node.is_clickable, node.is_focusable),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def episode_app(resource_ids: Iterator[str] | list[str]) -> str:
|
|
166
|
+
"""The dominant non-platform package among resource ids; "" when there is none."""
|
|
167
|
+
counts: Counter[str] = Counter()
|
|
168
|
+
for rid in resource_ids:
|
|
169
|
+
match = _PACKAGE.match(rid or "")
|
|
170
|
+
if match and match.group(1) not in SYSTEM_PACKAGES:
|
|
171
|
+
counts[match.group(1)] += 1
|
|
172
|
+
if not counts:
|
|
173
|
+
return ""
|
|
174
|
+
return min(counts.items(), key=lambda item: (-item[1], item[0]))[0]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def parse_episode(
|
|
178
|
+
example: Mapping[str, list], split: str = "unknown", shard: str = ""
|
|
179
|
+
) -> AcEpisode:
|
|
180
|
+
"""Decode one tf.train.Example (as returned by ``parse_example``) into screens of nodes."""
|
|
181
|
+
episode_id = int((example.get("episode_id") or [0])[0])
|
|
182
|
+
goal = (example.get("goal") or [b""])[0].decode("utf-8", "replace")
|
|
183
|
+
trees = example.get("accessibility_trees") or []
|
|
184
|
+
widths = example.get("screenshot_widths") or []
|
|
185
|
+
heights = example.get("screenshot_heights") or []
|
|
186
|
+
|
|
187
|
+
steps: list[tuple[int, int, tuple[AcNode, ...]]] = []
|
|
188
|
+
for step_index, blob in enumerate(trees):
|
|
189
|
+
try:
|
|
190
|
+
windows = parse_forest(blob)
|
|
191
|
+
except Exception: # a forest that will not decode is an empty screen, as in AISSA
|
|
192
|
+
windows = []
|
|
193
|
+
nodes = tuple(
|
|
194
|
+
_make_node(node, window_index, window.layer)
|
|
195
|
+
for window_index, window in enumerate(windows)
|
|
196
|
+
for node in window.nodes
|
|
197
|
+
)
|
|
198
|
+
width = int(widths[step_index]) if step_index < len(widths) else 0
|
|
199
|
+
height = int(heights[step_index]) if step_index < len(heights) else 0
|
|
200
|
+
steps.append((width, height, nodes))
|
|
201
|
+
|
|
202
|
+
app = episode_app([node.resource_id for _w, _h, nodes in steps for node in nodes])
|
|
203
|
+
screens = tuple(
|
|
204
|
+
AcScreen(
|
|
205
|
+
screen_id=f"{episode_id}_{step_index}",
|
|
206
|
+
app=app,
|
|
207
|
+
episode_id=episode_id,
|
|
208
|
+
step_index=step_index,
|
|
209
|
+
width=width,
|
|
210
|
+
height=height,
|
|
211
|
+
nodes=nodes,
|
|
212
|
+
)
|
|
213
|
+
for step_index, (width, height, nodes) in enumerate(steps)
|
|
214
|
+
)
|
|
215
|
+
return AcEpisode(
|
|
216
|
+
episode_id=episode_id,
|
|
217
|
+
split=split,
|
|
218
|
+
shard=shard,
|
|
219
|
+
goal=goal,
|
|
220
|
+
app=app,
|
|
221
|
+
n_actions=len(example.get("actions") or []),
|
|
222
|
+
screens=screens,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def load_splits(path: str | Path) -> dict[int, str]:
|
|
227
|
+
"""Map episode id -> split ("train", "validation", "test") from AndroidControl's splits.json."""
|
|
228
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
229
|
+
return {int(episode_id): split for split, ids in data.items() for episode_id in ids}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class ShardReader:
|
|
233
|
+
"""Iterate the episodes of one shard. A truncated shard stops early and sets ``truncated``;
|
|
234
|
+
an episode that fails to decode is skipped and listed in ``failures``."""
|
|
235
|
+
|
|
236
|
+
def __init__(
|
|
237
|
+
self, path: str | Path, splits: Mapping[int, str] | None = None, shard: str | None = None
|
|
238
|
+
) -> None:
|
|
239
|
+
self.path = Path(path)
|
|
240
|
+
self.shard = shard or self.path.name
|
|
241
|
+
self.splits = splits or {}
|
|
242
|
+
self.truncated = False
|
|
243
|
+
self.records = 0
|
|
244
|
+
self.failures: list[dict[str, str]] = []
|
|
245
|
+
|
|
246
|
+
def __iter__(self) -> Iterator[AcEpisode]:
|
|
247
|
+
records = iter_records(self.path)
|
|
248
|
+
while True:
|
|
249
|
+
try:
|
|
250
|
+
blob = next(records)
|
|
251
|
+
except StopIteration:
|
|
252
|
+
return
|
|
253
|
+
except TruncatedShardError:
|
|
254
|
+
self.truncated = True
|
|
255
|
+
return
|
|
256
|
+
self.records += 1
|
|
257
|
+
try:
|
|
258
|
+
example = parse_example(blob)
|
|
259
|
+
episode_id = int((example.get("episode_id") or [0])[0])
|
|
260
|
+
yield parse_episode(example, self.splits.get(episode_id, "unknown"), self.shard)
|
|
261
|
+
except Exception as exc: # one bad episode must not stop a 2.5 GB shard
|
|
262
|
+
self.failures.append(
|
|
263
|
+
{
|
|
264
|
+
"shard": self.shard,
|
|
265
|
+
"record": str(self.records),
|
|
266
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
267
|
+
}
|
|
268
|
+
)
|
a11ydebt/bootstrap.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Cluster-bootstrap intervals for pooled coverage (labelled / candidates).
|
|
2
|
+
|
|
3
|
+
Elements are nested in screens, and screens in apps, so element-level intervals are too narrow.
|
|
4
|
+
The cluster bootstrap resamples whole clusters with replacement and recomputes the pooled rate
|
|
5
|
+
``sum(labelled) / sum(candidates)``. The interval is the 2.5th and 97.5th percentile of the
|
|
6
|
+
draws.
|
|
7
|
+
|
|
8
|
+
Ported from accessibility-debt-agents (commit 97c0378, MIT; see NOTICE):
|
|
9
|
+
``cluster_bootstrap_rate`` and ``pct_ci`` from ``phase4_validation/03_reviewer_checks.py``,
|
|
10
|
+
``wilson_ci`` from ``src/a11ydebt/stats.py``, and the defaults (seed 20260908, 5,000 resamples)
|
|
11
|
+
from ``config.yaml``. AISSA's ``src/a11ydebt/bootstrap.py`` is only an import shim and holds no
|
|
12
|
+
bootstrap code.
|
|
13
|
+
|
|
14
|
+
The algorithm is unchanged: each resample draws ``rng.integers(0, m, m)`` cluster indices. Here
|
|
15
|
+
a block of resamples is drawn at once (``size=(rows, m)``), which consumes numpy's generator
|
|
16
|
+
exactly as AISSA's one-call-per-resample loop does. Given the same generator state, the draws are
|
|
17
|
+
bit-identical to AISSA's and the generator ends in the same state (``tests/test_bootstrap.py``).
|
|
18
|
+
|
|
19
|
+
Clusters follow AISSA's ReviewerChecks:
|
|
20
|
+
|
|
21
|
+
* ``screen``: each screen with at least one candidate is a cluster.
|
|
22
|
+
* ``app``: each app is a cluster. On AndroidControl, an episode with no identified app is its own
|
|
23
|
+
cluster (``episode:<id>``), so the interval is centred on the same estimate as the others. On
|
|
24
|
+
Rico, a screen with no app would be its own cluster (``screen:<id>``). AISSA drops such Rico
|
|
25
|
+
screens instead, but Rico has none, so the two rules give the same clusters.
|
|
26
|
+
|
|
27
|
+
Clusters are ordered by key (Python string order, the same as pandas ``groupby``), because the
|
|
28
|
+
order decides which cluster each drawn index picks.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from collections import defaultdict
|
|
34
|
+
from collections.abc import Iterable
|
|
35
|
+
from statistics import NormalDist
|
|
36
|
+
from typing import NamedTuple
|
|
37
|
+
|
|
38
|
+
import numpy as np
|
|
39
|
+
from pydantic import BaseModel, ConfigDict
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"DEFAULT_CONF",
|
|
43
|
+
"DEFAULT_N_RESAMPLES",
|
|
44
|
+
"DEFAULT_SEED",
|
|
45
|
+
"LEVELS",
|
|
46
|
+
"ClusteredInterval",
|
|
47
|
+
"ScreenCount",
|
|
48
|
+
"cluster_bootstrap_rate",
|
|
49
|
+
"cluster_key",
|
|
50
|
+
"cluster_totals",
|
|
51
|
+
"clustered_interval",
|
|
52
|
+
"percentile_ci",
|
|
53
|
+
"wilson_ci",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
#: AISSA config.yaml ``run.seed`` and ``run.n_bootstrap``.
|
|
57
|
+
DEFAULT_SEED = 20260908
|
|
58
|
+
DEFAULT_N_RESAMPLES = 5000
|
|
59
|
+
DEFAULT_CONF = 0.95
|
|
60
|
+
#: Cluster levels, in report order: apps (the primary interval), then screens.
|
|
61
|
+
LEVELS: tuple[str, ...] = ("app", "screen")
|
|
62
|
+
_BLOCK_ELEMENTS = 1 << 22 # cluster indices drawn per block: 32 MB of int64
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ScreenCount(NamedTuple):
|
|
66
|
+
"""One screen's counts for one population, as the bootstrap needs them."""
|
|
67
|
+
|
|
68
|
+
screen_id: str
|
|
69
|
+
app: str
|
|
70
|
+
labelled: int
|
|
71
|
+
candidates: int
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _quantile_levels(conf: float) -> tuple[float, float]:
|
|
75
|
+
# (1 - 0.95) / 2 is 0.025000000000000022 in floating point; rounding gives AISSA's literal
|
|
76
|
+
# 0.025 and 0.975, so the percentiles are bit-identical to its pct_ci.
|
|
77
|
+
if not 0 < conf < 1:
|
|
78
|
+
raise ValueError(f"conf must be between 0 and 1, got {conf}")
|
|
79
|
+
low = round((1 - conf) / 2, 10)
|
|
80
|
+
return low, round(1 - low, 10)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cluster_bootstrap_rate(
|
|
84
|
+
labelled: Iterable[int] | np.ndarray,
|
|
85
|
+
candidates: Iterable[int] | np.ndarray,
|
|
86
|
+
*,
|
|
87
|
+
n_resamples: int = DEFAULT_N_RESAMPLES,
|
|
88
|
+
seed: int | None = DEFAULT_SEED,
|
|
89
|
+
rng: np.random.Generator | None = None,
|
|
90
|
+
block_elements: int = _BLOCK_ELEMENTS,
|
|
91
|
+
) -> np.ndarray:
|
|
92
|
+
"""Bootstrap draws of the pooled rate ``sum(labelled) / sum(candidates)`` over clusters.
|
|
93
|
+
|
|
94
|
+
``labelled[i]`` and ``candidates[i]`` are cluster ``i``'s totals. Each of the
|
|
95
|
+
``n_resamples`` draws resamples the ``m`` clusters with replacement. Pass ``rng`` to continue
|
|
96
|
+
an existing generator (as AISSA does across its tables); otherwise a new generator is seeded
|
|
97
|
+
with ``seed``. ``block_elements`` only bounds memory and never changes the draws. A draw whose
|
|
98
|
+
resampled clusters have no candidates is NaN, and with no clusters every draw is NaN.
|
|
99
|
+
"""
|
|
100
|
+
k = np.asarray(labelled, dtype=np.int64)
|
|
101
|
+
n = np.asarray(candidates, dtype=np.int64)
|
|
102
|
+
if k.ndim != 1 or k.shape != n.shape:
|
|
103
|
+
raise ValueError("labelled and candidates must be 1-D arrays of the same length")
|
|
104
|
+
if n_resamples < 1:
|
|
105
|
+
raise ValueError(f"n_resamples must be at least 1, got {n_resamples}")
|
|
106
|
+
if (k < 0).any() or (k > n).any():
|
|
107
|
+
raise ValueError("every cluster needs 0 <= labelled <= candidates")
|
|
108
|
+
out = np.full(n_resamples, np.nan)
|
|
109
|
+
m = k.size
|
|
110
|
+
if m == 0:
|
|
111
|
+
return out
|
|
112
|
+
gen = rng if rng is not None else np.random.default_rng(seed)
|
|
113
|
+
rows = max(1, block_elements // m)
|
|
114
|
+
for start in range(0, n_resamples, rows):
|
|
115
|
+
stop = min(start + rows, n_resamples)
|
|
116
|
+
idx = gen.integers(0, m, size=(stop - start, m))
|
|
117
|
+
kk = k[idx].sum(axis=1)
|
|
118
|
+
nn = n[idx].sum(axis=1)
|
|
119
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
120
|
+
out[start:stop] = np.where(nn > 0, kk / nn, np.nan)
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def percentile_ci(draws: np.ndarray, conf: float = DEFAULT_CONF) -> tuple[float, float]:
|
|
125
|
+
"""Percentile interval of the draws, ignoring NaN (AISSA ``pct_ci``)."""
|
|
126
|
+
low, high = _quantile_levels(conf)
|
|
127
|
+
arr = np.asarray(draws, dtype=float)
|
|
128
|
+
if np.isnan(arr).all():
|
|
129
|
+
return float("nan"), float("nan")
|
|
130
|
+
return float(np.nanquantile(arr, low)), float(np.nanquantile(arr, high))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def wilson_ci(labelled: int, candidates: int, conf: float = DEFAULT_CONF) -> tuple[float, float]:
|
|
134
|
+
"""Element-level Wilson score interval (AISSA ``stats.wilson_ci``, with a stdlib z)."""
|
|
135
|
+
if candidates == 0:
|
|
136
|
+
return float("nan"), float("nan")
|
|
137
|
+
_quantile_levels(conf)
|
|
138
|
+
z = NormalDist().inv_cdf(1 - (1 - conf) / 2)
|
|
139
|
+
p = labelled / candidates
|
|
140
|
+
denom = 1 + z**2 / candidates
|
|
141
|
+
centre = (p + z**2 / (2 * candidates)) / denom
|
|
142
|
+
half = z * (p * (1 - p) / candidates + z**2 / (4 * candidates**2)) ** 0.5 / denom
|
|
143
|
+
return max(0.0, centre - half), min(1.0, centre + half)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _episode(screen_id: str) -> str:
|
|
147
|
+
# AndroidControl screen ids are f"{episode_id}_{step_index}" (a11ydebt.androidcontrol).
|
|
148
|
+
return screen_id.rsplit("_", 1)[0]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cluster_key(level: str, dataset: str, screen_id: str, app: str) -> str:
|
|
152
|
+
"""The cluster a screen belongs to at ``level`` (``"app"`` or ``"screen"``)."""
|
|
153
|
+
if level == "screen":
|
|
154
|
+
return screen_id
|
|
155
|
+
if level != "app":
|
|
156
|
+
raise ValueError(f"level must be one of {LEVELS}, got {level!r}")
|
|
157
|
+
if app:
|
|
158
|
+
return app
|
|
159
|
+
if dataset == "androidcontrol":
|
|
160
|
+
return f"episode:{_episode(screen_id)}"
|
|
161
|
+
return f"screen:{screen_id}"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cluster_totals(
|
|
165
|
+
screens: Iterable[ScreenCount], *, level: str, dataset: str
|
|
166
|
+
) -> tuple[list[str], np.ndarray, np.ndarray]:
|
|
167
|
+
"""Sorted cluster keys with their labelled and candidate totals.
|
|
168
|
+
|
|
169
|
+
Only screens with at least one candidate contribute, so every cluster has candidates.
|
|
170
|
+
"""
|
|
171
|
+
labelled: dict[str, int] = defaultdict(int)
|
|
172
|
+
candidates: dict[str, int] = defaultdict(int)
|
|
173
|
+
for s in screens:
|
|
174
|
+
if s.candidates <= 0:
|
|
175
|
+
continue
|
|
176
|
+
key = cluster_key(level, dataset, s.screen_id, s.app)
|
|
177
|
+
labelled[key] += s.labelled
|
|
178
|
+
candidates[key] += s.candidates
|
|
179
|
+
keys = sorted(candidates)
|
|
180
|
+
return (
|
|
181
|
+
keys,
|
|
182
|
+
np.array([labelled[key] for key in keys], dtype=np.int64),
|
|
183
|
+
np.array([candidates[key] for key in keys], dtype=np.int64),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class ClusteredInterval(BaseModel):
|
|
188
|
+
"""Pooled coverage with its cluster-bootstrap percentile interval."""
|
|
189
|
+
|
|
190
|
+
model_config = ConfigDict(frozen=True)
|
|
191
|
+
|
|
192
|
+
level: str
|
|
193
|
+
n_clusters: int
|
|
194
|
+
labelled: int
|
|
195
|
+
candidates: int
|
|
196
|
+
coverage: float | None
|
|
197
|
+
ci_low: float | None
|
|
198
|
+
ci_high: float | None
|
|
199
|
+
n_resamples: int
|
|
200
|
+
seed: int | None
|
|
201
|
+
conf: float
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def clustered_interval(
|
|
205
|
+
screens: Iterable[ScreenCount],
|
|
206
|
+
*,
|
|
207
|
+
level: str,
|
|
208
|
+
dataset: str,
|
|
209
|
+
n_resamples: int = DEFAULT_N_RESAMPLES,
|
|
210
|
+
seed: int | None = DEFAULT_SEED,
|
|
211
|
+
conf: float = DEFAULT_CONF,
|
|
212
|
+
rng: np.random.Generator | None = None,
|
|
213
|
+
) -> ClusteredInterval:
|
|
214
|
+
"""Coverage and its ``conf`` cluster-bootstrap interval at ``level`` for one population."""
|
|
215
|
+
_keys, k, n = cluster_totals(screens, level=level, dataset=dataset)
|
|
216
|
+
draws = cluster_bootstrap_rate(k, n, n_resamples=n_resamples, seed=seed, rng=rng)
|
|
217
|
+
low, high = percentile_ci(draws, conf)
|
|
218
|
+
total_k, total_n = int(k.sum()), int(n.sum())
|
|
219
|
+
return ClusteredInterval(
|
|
220
|
+
level=level,
|
|
221
|
+
n_clusters=len(k),
|
|
222
|
+
labelled=total_k,
|
|
223
|
+
candidates=total_n,
|
|
224
|
+
coverage=total_k / total_n if total_n else None,
|
|
225
|
+
ci_low=None if np.isnan(low) else low,
|
|
226
|
+
ci_high=None if np.isnan(high) else high,
|
|
227
|
+
n_resamples=n_resamples,
|
|
228
|
+
seed=seed if rng is None else None,
|
|
229
|
+
conf=conf,
|
|
230
|
+
)
|