views-frames 1.1.1__tar.gz → 1.2.0__tar.gz
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.
- {views_frames-1.1.1 → views_frames-1.2.0}/PKG-INFO +13 -10
- {views_frames-1.1.1 → views_frames-1.2.0}/README.md +12 -9
- {views_frames-1.1.1 → views_frames-1.2.0}/pyproject.toml +1 -1
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/bimodality.py +26 -20
- views_frames-1.2.0/src/views_frames_summarize/config.py +89 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/conformance.py +10 -4
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/summarize_tower.py +19 -12
- views_frames-1.2.0/src/views_frames_summarize/tower.py +224 -0
- views_frames-1.2.0/src/views_frames_summarize/tower_point.py +59 -0
- views_frames-1.1.1/src/views_frames_summarize/tower.py +0 -192
- views_frames-1.1.1/src/views_frames_summarize/tower_point.py +0 -46
- {views_frames-1.1.1 → views_frames-1.2.0}/.gitignore +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/LICENSE +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/__init__.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/_typing.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/_validation.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/conformance/__init__.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/feature_frame.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/index.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/io/__init__.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/io/arrow.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/io/npz.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/metadata.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/prediction_frame.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/protocols.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/py.typed +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/spatial_level.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/target_frame.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/__init__.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/_common.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/aggregate.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/collapse.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/interval.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/point.py +0 -0
- {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: views-frames
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: The VIEWS platform data-contract layer: immutable array+identifier frames (numpy only, root of the dependency DAG).
|
|
5
5
|
Project-URL: Homepage, https://github.com/views-platform/views-frames
|
|
6
6
|
Project-URL: Repository, https://github.com/views-platform/views-frames
|
|
@@ -30,8 +30,9 @@ Description-Content-Type: text/markdown
|
|
|
30
30
|
> containers (`FeatureFrame`, `PredictionFrame`, and their anticipated siblings)
|
|
31
31
|
> that every other repo depends on and that depends on nothing internal.
|
|
32
32
|
>
|
|
33
|
-
> **Status:** **v1.
|
|
34
|
-
>
|
|
33
|
+
> **Status:** **v1.2.0 — frozen API** (frozen since v1.0.0, ADR-018; the v1.1 surface is
|
|
34
|
+
> purely additive — the coherent posterior summary, ADR-019; v1.2.0 rebuilds the tower
|
|
35
|
+
> `outside-in` and makes it config-driven, register C-44). This README is the design
|
|
35
36
|
> bible; the contract it specifies is realised in `src/views_frames/` (index, frames,
|
|
36
37
|
> io, conformance suite) plus the `src/views_frames_summarize/` sibling package
|
|
37
38
|
> (sample-axis summarization — `collapse`/MAP/HDI/quantiles, the coherent-tower estimators
|
|
@@ -95,14 +96,16 @@ Both are numpy-only. For the subtler cm↔pgm surface — a time-varying
|
|
|
95
96
|
`aggregate_distributions` (`HDI(sum) ≠ sum(HDI)`) — see
|
|
96
97
|
[`examples/cross_level.py`](examples/cross_level.py).
|
|
97
98
|
|
|
98
|
-
**Which estimator? (two coherent paths, v1.
|
|
99
|
+
**Which estimator? (two coherent paths, v1.2.0).** Each frozen estimator has a
|
|
99
100
|
coherent-tower sibling (ADR-019): `map_estimate` ↔ `tower_point` (an unbinned
|
|
100
|
-
median-of-the
|
|
101
|
-
and
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
median-of-the-`tip_mass`-floor point — the "shorth", free of `map_estimate`'s histogram
|
|
102
|
+
tie-break bias **and** robust to minority duplicated draws, register C-44), and
|
|
103
|
+
`hdi`/`quantiles` ↔ `hdi_tower(masses=…)` (HDIs built **outside-in**, nested **by
|
|
104
|
+
construction** and reproducible — a mass's interval is identical regardless of which others
|
|
105
|
+
you request), with `summarize_tower` returning all three in one pass. The tower's tunables
|
|
106
|
+
live in `views_frames_summarize.config` (fail-loud, no silent defaults). Use the **frozen**
|
|
107
|
+
estimators for parity with existing pipelines; use the **tower** path when you need coherent,
|
|
108
|
+
reproducible bands plus a matching, duplicate-robust point.
|
|
106
109
|
|
|
107
110
|
**Reading the `bimodality` flag.** It is a *deliberately conservative* heuristic, **not** a
|
|
108
111
|
formal multimodality test — tuned for **zero false positives** at the cost of recall on
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
> containers (`FeatureFrame`, `PredictionFrame`, and their anticipated siblings)
|
|
5
5
|
> that every other repo depends on and that depends on nothing internal.
|
|
6
6
|
>
|
|
7
|
-
> **Status:** **v1.
|
|
8
|
-
>
|
|
7
|
+
> **Status:** **v1.2.0 — frozen API** (frozen since v1.0.0, ADR-018; the v1.1 surface is
|
|
8
|
+
> purely additive — the coherent posterior summary, ADR-019; v1.2.0 rebuilds the tower
|
|
9
|
+
> `outside-in` and makes it config-driven, register C-44). This README is the design
|
|
9
10
|
> bible; the contract it specifies is realised in `src/views_frames/` (index, frames,
|
|
10
11
|
> io, conformance suite) plus the `src/views_frames_summarize/` sibling package
|
|
11
12
|
> (sample-axis summarization — `collapse`/MAP/HDI/quantiles, the coherent-tower estimators
|
|
@@ -69,14 +70,16 @@ Both are numpy-only. For the subtler cm↔pgm surface — a time-varying
|
|
|
69
70
|
`aggregate_distributions` (`HDI(sum) ≠ sum(HDI)`) — see
|
|
70
71
|
[`examples/cross_level.py`](examples/cross_level.py).
|
|
71
72
|
|
|
72
|
-
**Which estimator? (two coherent paths, v1.
|
|
73
|
+
**Which estimator? (two coherent paths, v1.2.0).** Each frozen estimator has a
|
|
73
74
|
coherent-tower sibling (ADR-019): `map_estimate` ↔ `tower_point` (an unbinned
|
|
74
|
-
median-of-the
|
|
75
|
-
and
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
median-of-the-`tip_mass`-floor point — the "shorth", free of `map_estimate`'s histogram
|
|
76
|
+
tie-break bias **and** robust to minority duplicated draws, register C-44), and
|
|
77
|
+
`hdi`/`quantiles` ↔ `hdi_tower(masses=…)` (HDIs built **outside-in**, nested **by
|
|
78
|
+
construction** and reproducible — a mass's interval is identical regardless of which others
|
|
79
|
+
you request), with `summarize_tower` returning all three in one pass. The tower's tunables
|
|
80
|
+
live in `views_frames_summarize.config` (fail-loud, no silent defaults). Use the **frozen**
|
|
81
|
+
estimators for parity with existing pipelines; use the **tower** path when you need coherent,
|
|
82
|
+
reproducible bands plus a matching, duplicate-robust point.
|
|
80
83
|
|
|
81
84
|
**Reading the `bimodality` flag.** It is a *deliberately conservative* heuristic, **not** a
|
|
82
85
|
formal multimodality test — tuned for **zero false positives** at the cost of recall on
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "views-frames"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.2.0"
|
|
8
8
|
description = "The VIEWS platform data-contract layer: immutable array+identifier frames (numpy only, root of the dependency DAG)."
|
|
9
9
|
authors = [
|
|
10
10
|
{ name = "Simon Polichinel von der Maase", email = "simmaa@prio.org" },
|
|
@@ -26,11 +26,10 @@ from __future__ import annotations
|
|
|
26
26
|
import numpy as np
|
|
27
27
|
from numpy.typing import NDArray
|
|
28
28
|
|
|
29
|
-
from views_frames_summarize
|
|
29
|
+
from views_frames_summarize import config
|
|
30
|
+
from views_frames_summarize._common import AnyFrame, block_apply
|
|
30
31
|
from views_frames_summarize.tower import _zero_mask
|
|
31
32
|
|
|
32
|
-
_SMOOTH = 3 # moving-average window over bins; tames sparse-histogram flicker
|
|
33
|
-
|
|
34
33
|
|
|
35
34
|
def _coarse_counts(flat: NDArray[np.float32], bins: int) -> NDArray[np.intp]:
|
|
36
35
|
"""Per-row histogram counts ``(rows, bins)`` over each row's ``[min, max]``.
|
|
@@ -49,24 +48,32 @@ def _coarse_counts(flat: NDArray[np.float32], bins: int) -> NDArray[np.intp]:
|
|
|
49
48
|
|
|
50
49
|
|
|
51
50
|
def _bimodal_block(
|
|
52
|
-
block: NDArray[np.float32],
|
|
51
|
+
block: NDArray[np.float32],
|
|
52
|
+
bins: int,
|
|
53
|
+
prominence: float,
|
|
54
|
+
min_mass: float,
|
|
55
|
+
smooth: int,
|
|
53
56
|
) -> NDArray[np.float32]:
|
|
54
|
-
"""Flag (0/1) per row of a block: ≥ 2 separated regions each holding enough mass.
|
|
57
|
+
"""Flag (0/1) per row of a block: ≥ 2 separated regions each holding enough mass.
|
|
58
|
+
|
|
59
|
+
``smooth`` is the moving-average window width (the 3-tap normalization; config
|
|
60
|
+
``bimodality_smooth``). It tames sparse-histogram flicker.
|
|
61
|
+
"""
|
|
55
62
|
rows = block.shape[0]
|
|
56
63
|
counts = _coarse_counts(block, bins).astype(np.float64)
|
|
57
64
|
|
|
58
|
-
# Light moving-average smoothing (window
|
|
65
|
+
# Light moving-average smoothing (window ``smooth``) over the bins. Normalise each
|
|
59
66
|
# position by the number of *real* bins in its window — the two edge bins have one
|
|
60
67
|
# fewer neighbour, so dividing them by the full window would deflate an edge peak
|
|
61
68
|
# (e.g. the zero atom, which always lands in bin 0) and hide an atom+bump bimodal.
|
|
62
69
|
pad = np.zeros((rows, 1))
|
|
63
70
|
padded = np.concatenate([pad, counts, pad], axis=1)
|
|
64
71
|
window_sum = padded[:, :-2] + padded[:, 1:-1] + padded[:, 2:]
|
|
65
|
-
divisor = np.full(bins,
|
|
66
|
-
divisor[0] = divisor[-1] =
|
|
67
|
-
|
|
72
|
+
divisor = np.full(bins, smooth, dtype=np.float64)
|
|
73
|
+
divisor[0] = divisor[-1] = smooth - 1 # edges contribute one fewer real bin
|
|
74
|
+
smoothed = window_sum / divisor
|
|
68
75
|
|
|
69
|
-
significant =
|
|
76
|
+
significant = smoothed >= prominence * smoothed.max(axis=1, keepdims=True)
|
|
70
77
|
prev = np.concatenate(
|
|
71
78
|
[np.zeros((rows, 1), dtype=bool), significant[:, :-1]], axis=1
|
|
72
79
|
)
|
|
@@ -87,27 +94,26 @@ def _bimodal_block(
|
|
|
87
94
|
return flag
|
|
88
95
|
|
|
89
96
|
|
|
90
|
-
def bimodality(
|
|
91
|
-
frame: AnyFrame,
|
|
92
|
-
*,
|
|
93
|
-
bins: int = 16,
|
|
94
|
-
prominence: float = 0.40,
|
|
95
|
-
min_mass: float = 0.15,
|
|
96
|
-
block_rows: int = ROW_BLOCK,
|
|
97
|
-
) -> NDArray[np.float32]:
|
|
97
|
+
def bimodality(frame: AnyFrame) -> NDArray[np.float32]:
|
|
98
98
|
"""Per-row bimodality flag over the sample axis → ``(N, …, 1)`` of ``0.0``/``1.0``.
|
|
99
99
|
|
|
100
100
|
Conservative heuristic (see module docstring): ≥ 2 separated density regions, each
|
|
101
101
|
holding ≥ ``min_mass`` of the samples, after coarse binning + light smoothing.
|
|
102
|
-
Aligned to ``frame.index``.
|
|
102
|
+
Aligned to ``frame.index``. Tunables (``bins``, ``prominence``, ``min_mass``,
|
|
103
|
+
``smooth``, row-block) come from ``config`` — no silent defaults (ADR-009).
|
|
103
104
|
"""
|
|
104
105
|
values = frame.values
|
|
105
106
|
lead = values.shape[:-1]
|
|
106
107
|
s = values.shape[-1]
|
|
108
|
+
bins = int(config.get("bimodality_bins"))
|
|
109
|
+
prominence = float(config.get("bimodality_prominence"))
|
|
110
|
+
min_mass = float(config.get("bimodality_min_mass"))
|
|
111
|
+
smooth = int(config.get("bimodality_smooth"))
|
|
112
|
+
block_rows = int(config.get("row_block"))
|
|
107
113
|
flat = np.ascontiguousarray(values).reshape(-1, s)
|
|
108
114
|
|
|
109
115
|
def _block(block: NDArray[np.float32]) -> NDArray[np.float32]:
|
|
110
|
-
return _bimodal_block(block, bins, prominence, min_mass)
|
|
116
|
+
return _bimodal_block(block, bins, prominence, min_mass, smooth)
|
|
111
117
|
|
|
112
118
|
out = block_apply(flat, block_rows, _block)
|
|
113
119
|
return np.asarray(out, dtype=np.float32).reshape(lead)[..., np.newaxis]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Tower-family tunables (ADR-019) — a fail-loud config dict, no silent defaults.
|
|
2
|
+
|
|
3
|
+
views-models style: a plain dict of tunables, validated at entry, with **no defaults
|
|
4
|
+
anywhere downstream**. ADR-008 (explicit failure) + ADR-009 ("No semantic defaults may
|
|
5
|
+
exist silently"): a tower-family function reads each parameter from here, and a missing
|
|
6
|
+
key raises a ``ValueError`` naming it rather than falling back to a hidden literal.
|
|
7
|
+
|
|
8
|
+
``masses`` is **not** here — it stays a per-call argument (it is *what* a caller asks
|
|
9
|
+
for, not a tunable). The frozen estimators (``map_estimate``/``hdi``/``quantiles``,
|
|
10
|
+
ADR-018) are **out of scope**; this config governs only the tower family
|
|
11
|
+
(``hdi_tower``/``tower_point``/``bimodality``/``summarize_tower``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, Final
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
from numpy.typing import NDArray
|
|
20
|
+
|
|
21
|
+
# The single source of truth for every tower-family tunable. Edit values here; the
|
|
22
|
+
# algorithm code carries no fallback literals (ADR-009).
|
|
23
|
+
TOWER_CONFIG: Final[dict[str, Any]] = {
|
|
24
|
+
# The fixed canonical mass grid: a 5% body + a fine high-mass tail to 0.99. Built
|
|
25
|
+
# from rounded literals — never ``np.arange`` (which drifts ~1 ulp across numpy
|
|
26
|
+
# versions and would make every pinned interval non-reproducible; register C-24).
|
|
27
|
+
"canonical_floors": tuple(
|
|
28
|
+
[round(0.05 * i, 2) for i in range(1, 19)] # 0.05 … 0.90
|
|
29
|
+
+ [0.92, 0.94, 0.95, 0.96, 0.97, 0.98, 0.99] # fine high-mass tail
|
|
30
|
+
),
|
|
31
|
+
# The tower-tip reads the median of the floor at this mass — the "shorth". 0.5 is
|
|
32
|
+
# the maximally-robust choice: a duplicate would need ~half the draws to hijack it.
|
|
33
|
+
"tip_mass": 0.5,
|
|
34
|
+
# Raw-count zero rule: a row whose every draw is <= this collapses to 0 (the quiet
|
|
35
|
+
# cell). A *count* rule, distinct from ``map_estimate``'s zero-mass-fraction rule.
|
|
36
|
+
"zero_cutoff": 1.0,
|
|
37
|
+
# Bimodality detector (coarse histogram + light smoothing; see bimodality.py).
|
|
38
|
+
"bimodality_bins": 16,
|
|
39
|
+
"bimodality_prominence": 0.40,
|
|
40
|
+
"bimodality_min_mass": 0.15,
|
|
41
|
+
"bimodality_smooth": 3,
|
|
42
|
+
# Row-block size for the memory-bounded estimators (register C-22/C-25).
|
|
43
|
+
"row_block": 1 << 16,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Every key a tower-family function may read. The completeness check is keyed off this,
|
|
47
|
+
# so adding a reader without a corresponding key fails the test suite, not production.
|
|
48
|
+
REQUIRED_KEYS: Final[frozenset[str]] = frozenset(
|
|
49
|
+
{
|
|
50
|
+
"canonical_floors",
|
|
51
|
+
"tip_mass",
|
|
52
|
+
"zero_cutoff",
|
|
53
|
+
"bimodality_bins",
|
|
54
|
+
"bimodality_prominence",
|
|
55
|
+
"bimodality_min_mass",
|
|
56
|
+
"bimodality_smooth",
|
|
57
|
+
"row_block",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def validate_config(cfg: dict[str, Any]) -> None:
|
|
63
|
+
"""Raise ``ValueError`` listing any missing required key (ADR-009 completeness)."""
|
|
64
|
+
missing = REQUIRED_KEYS - cfg.keys()
|
|
65
|
+
if missing:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"tower config is missing required key(s): {sorted(missing)}; "
|
|
68
|
+
"no semantic defaults may exist silently (ADR-009)"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get(key: str, cfg: dict[str, Any] | None = None) -> Any:
|
|
73
|
+
"""Fail-loud accessor: raise naming the key rather than returning a default."""
|
|
74
|
+
cfg = TOWER_CONFIG if cfg is None else cfg
|
|
75
|
+
if key not in cfg:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"tower config key {key!r} is not set; no silent default (ADR-008/009). "
|
|
78
|
+
f"known keys: {sorted(cfg)}"
|
|
79
|
+
)
|
|
80
|
+
return cfg[key]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def canonical_floors(cfg: dict[str, Any] | None = None) -> NDArray[np.float64]:
|
|
84
|
+
"""The fixed grid as a float64 array (rounded literals; never ``np.arange``)."""
|
|
85
|
+
return np.asarray(get("canonical_floors", cfg), dtype=np.float64)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Import-time guarantee: the shipped config is itself complete.
|
|
89
|
+
validate_config(TOWER_CONFIG)
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import numpy as np
|
|
11
11
|
|
|
12
|
+
from views_frames_summarize import config
|
|
12
13
|
from views_frames_summarize._common import AnyFrame
|
|
13
14
|
from views_frames_summarize.bimodality import bimodality
|
|
14
15
|
from views_frames_summarize.collapse import collapse
|
|
@@ -69,10 +70,15 @@ def _assert_tower_contract(frame: AnyFrame, n: int) -> None:
|
|
|
69
70
|
"tower uppers must be non-decreasing"
|
|
70
71
|
)
|
|
71
72
|
|
|
72
|
-
# Tip-in-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
# Tip-in-tip_mass-floor law (ADR-019, outside-in redesign): the point is the median
|
|
74
|
+
# of the configured ``tip_mass`` floor, so it lies inside that floor. (It is *not*
|
|
75
|
+
# tied to the narrowest *requested* floor any longer — a caller may request a
|
|
76
|
+
# narrower or wider band than ``tip_mass``.)
|
|
77
|
+
tip_mass = float(config.get("tip_mass"))
|
|
78
|
+
tip_floor = hdi_tower(frame, masses=(tip_mass,))
|
|
79
|
+
tlo, thi = tip_floor[..., 0, 0], tip_floor[..., 0, 1]
|
|
80
|
+
assert (tip.values[..., 0] >= tlo - 1e-6).all(), "tip below the tip_mass floor"
|
|
81
|
+
assert (tip.values[..., 0] <= thi + 1e-6).all(), "tip above the tip_mass floor"
|
|
76
82
|
|
|
77
83
|
# Reproducibility law: the 50% HDI is independent of the other requested masses.
|
|
78
84
|
just_50 = hdi_tower(frame, masses=(0.5,))
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
the canonical tower **once**, deriving the tower-tip point, the pinned nested HDIs, and
|
|
5
5
|
the bimodality flag together. It returns exactly what the three composable functions
|
|
6
6
|
(`tower_point`, `hdi_tower`, `bimodality`) return — the bundle is purely an efficiency
|
|
7
|
-
collapse of the three, not a different computation.
|
|
7
|
+
collapse of the three, not a different computation. All tunables come from `config`.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
@@ -15,14 +15,16 @@ from typing import NamedTuple
|
|
|
15
15
|
import numpy as np
|
|
16
16
|
from numpy.typing import NDArray
|
|
17
17
|
|
|
18
|
-
from views_frames_summarize
|
|
18
|
+
from views_frames_summarize import config
|
|
19
|
+
from views_frames_summarize._common import AnyFrame, rebuild
|
|
19
20
|
from views_frames_summarize.bimodality import _bimodal_block
|
|
20
21
|
from views_frames_summarize.tower import (
|
|
21
22
|
_CANONICAL_FLOORS,
|
|
22
23
|
_dense_tower,
|
|
23
24
|
_ks,
|
|
25
|
+
_median_in,
|
|
24
26
|
_pin,
|
|
25
|
-
|
|
27
|
+
_tip_floor_index,
|
|
26
28
|
_zero_mask,
|
|
27
29
|
)
|
|
28
30
|
|
|
@@ -46,11 +48,6 @@ class TowerSummary(NamedTuple):
|
|
|
46
48
|
def summarize_tower(
|
|
47
49
|
frame: AnyFrame,
|
|
48
50
|
masses: Sequence[float] = (0.5, 0.9, 0.99),
|
|
49
|
-
*,
|
|
50
|
-
bins: int = 16,
|
|
51
|
-
prominence: float = 0.40,
|
|
52
|
-
min_mass: float = 0.15,
|
|
53
|
-
block_rows: int = ROW_BLOCK,
|
|
54
51
|
) -> TowerSummary:
|
|
55
52
|
"""Point + nested HDIs + bimodality flag in one sort-once pass → `TowerSummary`."""
|
|
56
53
|
values = frame.values
|
|
@@ -58,7 +55,12 @@ def summarize_tower(
|
|
|
58
55
|
s = values.shape[-1]
|
|
59
56
|
ks = _ks(s)
|
|
60
57
|
pin = _pin(masses)
|
|
61
|
-
|
|
58
|
+
t = _tip_floor_index(s)
|
|
59
|
+
bins = int(config.get("bimodality_bins"))
|
|
60
|
+
prominence = float(config.get("bimodality_prominence"))
|
|
61
|
+
min_mass = float(config.get("bimodality_min_mass"))
|
|
62
|
+
smooth = int(config.get("bimodality_smooth"))
|
|
63
|
+
block_rows = int(config.get("row_block"))
|
|
62
64
|
flat = np.ascontiguousarray(values).reshape(-1, s)
|
|
63
65
|
rows = flat.shape[0]
|
|
64
66
|
|
|
@@ -71,14 +73,19 @@ def summarize_tower(
|
|
|
71
73
|
srt = np.sort(block, axis=-1)
|
|
72
74
|
zero = _zero_mask(block)
|
|
73
75
|
|
|
74
|
-
|
|
76
|
+
tower = _dense_tower(srt, ks)
|
|
77
|
+
sel = tower[:, pin, :]
|
|
75
78
|
sel[zero] = 0.0
|
|
76
|
-
tip = np.where(
|
|
79
|
+
tip = np.where(
|
|
80
|
+
zero, np.float32(0.0), _median_in(srt, tower[:, t, 0], tower[:, t, 1])
|
|
81
|
+
)
|
|
77
82
|
|
|
78
83
|
stop = start + block.shape[0]
|
|
79
84
|
point_flat[start:stop] = tip
|
|
80
85
|
intervals_flat[start:stop] = sel
|
|
81
|
-
bimodal_flat[start:stop] = _bimodal_block(
|
|
86
|
+
bimodal_flat[start:stop] = _bimodal_block(
|
|
87
|
+
block, bins, prominence, min_mass, smooth
|
|
88
|
+
)
|
|
82
89
|
|
|
83
90
|
point = rebuild(frame, point_flat.reshape(lead)[..., np.newaxis])
|
|
84
91
|
intervals = intervals_flat.reshape(*lead, pin.shape[0], 2)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""The constrained-nested HDI tower (ADR-019) — interval estimates over the sample axis.
|
|
2
|
+
|
|
3
|
+
`hdi_tower` returns, for each requested mass, the highest-density interval read out of
|
|
4
|
+
a **fixed canonical tower** built **outside-in**: the widest floor is the shortest
|
|
5
|
+
interval holding its mass, then each *narrower* floor is the shortest interval
|
|
6
|
+
**contained in** its wider parent. Nesting is guaranteed **by construction** (register
|
|
7
|
+
C-33), and — crucially — the build is **robust to minority duplicated draws** (register
|
|
8
|
+
C-44): a lonely outlier (a couple of exact zeros, a stray pair) is shed by the
|
|
9
|
+
well-determined wide floors, and the containment constraint forbids any narrower floor
|
|
10
|
+
from re-selecting a window that pokes outside its parent. Because the tower is always
|
|
11
|
+
built on the same fixed `_CANONICAL_FLOORS` grid — requested masses are *pinned* to the
|
|
12
|
+
nearest floor, never inserted — "the 50% HDI" is identical regardless of which other
|
|
13
|
+
masses a caller asks for (reproducibility).
|
|
14
|
+
|
|
15
|
+
The construction is vectorized over the sample axis and runs in row-blocks (register
|
|
16
|
+
C-22/C-25): one sort per block, never a whole-grid sorted copy. A raw-count zero
|
|
17
|
+
short-circuit (`max(row) <= zero_cutoff` → the whole summary collapses to 0) kills the
|
|
18
|
+
quiet cells cheaply.
|
|
19
|
+
|
|
20
|
+
All tunables (the grid, zero cutoff, row-block size) come from `config` with **no silent
|
|
21
|
+
defaults** (ADR-009). The private engine (`_dense_tower`, `_median_in`, `_pin`,
|
|
22
|
+
`_zero_mask`, `_CANONICAL_FLOORS`) is shared by `tower_point`, `bimodality`, and the
|
|
23
|
+
single-pass `summarize_tower` bundle.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections.abc import Sequence
|
|
29
|
+
from typing import Final
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
from numpy.typing import NDArray
|
|
33
|
+
|
|
34
|
+
from views_frames_summarize import config
|
|
35
|
+
from views_frames_summarize._common import AnyFrame, block_apply
|
|
36
|
+
|
|
37
|
+
# The fixed canonical mass grid + the zero cutoff, sourced from the single config (no
|
|
38
|
+
# silent defaults — ADR-009). Kept as module names so the engine and its siblings share
|
|
39
|
+
# one source of truth.
|
|
40
|
+
_CANONICAL_FLOORS: Final[NDArray[np.float64]] = config.canonical_floors()
|
|
41
|
+
_ZERO_CUTOFF: Final[float] = float(config.get("zero_cutoff"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ks(sample_count: int) -> NDArray[np.intp]:
|
|
45
|
+
"""The per-floor window size ``k = floor(mass * S)`` for every canonical floor."""
|
|
46
|
+
return np.asarray(np.floor(_CANONICAL_FLOORS * sample_count), dtype=np.intp)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _pin(masses: Sequence[float]) -> NDArray[np.intp]:
|
|
50
|
+
"""Index of the nearest canonical floor for each requested mass.
|
|
51
|
+
|
|
52
|
+
Deterministic: ``argmin`` of the distance, breaking ties on the lowest index. A
|
|
53
|
+
mass at the midpoint between two floors (e.g. ``0.075`` between ``0.05`` and
|
|
54
|
+
``0.10``) therefore pins **down** to the lower floor — unambiguous and
|
|
55
|
+
reproducible. Fails loud (ADR-008) on a mass outside ``(0, 1)`` rather than silently
|
|
56
|
+
pinning a nonsense value to the nearest floor and returning a plausible interval.
|
|
57
|
+
"""
|
|
58
|
+
m = np.asarray(masses, dtype=np.float64)
|
|
59
|
+
if m.size == 0 or not np.all((m > 0.0) & (m < 1.0)):
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"masses must each be in the open interval (0, 1); got {masses!r}"
|
|
62
|
+
)
|
|
63
|
+
dist = np.abs(_CANONICAL_FLOORS[None, :] - m[:, None])
|
|
64
|
+
return np.asarray(np.argmin(dist, axis=1), dtype=np.intp)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _zero_mask(block: NDArray[np.float32]) -> NDArray[np.bool_]:
|
|
68
|
+
"""Rows whose maximum draw is at/below the zero cutoff — the quiet cells."""
|
|
69
|
+
return np.asarray(block.max(axis=-1) <= _ZERO_CUTOFF, dtype=np.bool_)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _in_range_span(
|
|
73
|
+
srt: NDArray[np.float32], lo: NDArray[np.float32], hi: NDArray[np.float32]
|
|
74
|
+
) -> tuple[NDArray[np.intp], NDArray[np.intp]]:
|
|
75
|
+
"""``(first, count)`` of the contiguous run of samples within ``[lo, hi]`` per row.
|
|
76
|
+
|
|
77
|
+
``lo``/``hi`` are sample values (a floor's bounds), so the in-range samples form a
|
|
78
|
+
contiguous run; at least one is always in range (the floor was built from samples).
|
|
79
|
+
"""
|
|
80
|
+
inside = (srt >= lo[:, None]) & (srt <= hi[:, None])
|
|
81
|
+
first = np.argmax(inside, axis=1) # index of the first in-range sample
|
|
82
|
+
count = inside.sum(axis=1)
|
|
83
|
+
return np.asarray(first, dtype=np.intp), np.asarray(count, dtype=np.intp)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _median_in(
|
|
87
|
+
srt: NDArray[np.float32], lo: NDArray[np.float32], hi: NDArray[np.float32]
|
|
88
|
+
) -> NDArray[np.float32]:
|
|
89
|
+
"""Per-row **median value** of the samples within ``[lo, hi]`` (averaged for an even
|
|
90
|
+
count). Used for the *tip point estimate*, which may fall between two samples.
|
|
91
|
+
"""
|
|
92
|
+
first, count = _in_range_span(srt, lo, hi)
|
|
93
|
+
a = first + (count - 1) // 2
|
|
94
|
+
b = first + count // 2
|
|
95
|
+
va = np.take_along_axis(srt, a[:, None], axis=1)[:, 0]
|
|
96
|
+
vb = np.take_along_axis(srt, b[:, None], axis=1)[:, 0]
|
|
97
|
+
return np.asarray((va + vb) * 0.5, dtype=np.float32)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _mid_sample_in(
|
|
101
|
+
srt: NDArray[np.float32], lo: NDArray[np.float32], hi: NDArray[np.float32]
|
|
102
|
+
) -> NDArray[np.float32]:
|
|
103
|
+
"""Per-row **middle sample** (a real draw) of the run within ``[lo, hi]``.
|
|
104
|
+
|
|
105
|
+
Used for a ``k <= 0`` *floor*, whose bounds must be actual samples so that the next
|
|
106
|
+
narrower floor's containment (and `_median_in`) stays well-defined — an averaged
|
|
107
|
+
median could be a value no draw equals, breaking the contiguous-run assumption.
|
|
108
|
+
"""
|
|
109
|
+
first, count = _in_range_span(srt, lo, hi)
|
|
110
|
+
mid = first + (count - 1) // 2
|
|
111
|
+
return np.asarray(
|
|
112
|
+
np.take_along_axis(srt, mid[:, None], axis=1)[:, 0], dtype=np.float32
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _shortest_seed(
|
|
117
|
+
srt: NDArray[np.float32], k: int
|
|
118
|
+
) -> tuple[NDArray[np.float32], NDArray[np.float32]]:
|
|
119
|
+
"""The widest floor (no parent): the shortest interval holding ``k + 1`` samples.
|
|
120
|
+
|
|
121
|
+
Leftmost tie-break (``np.argmin`` returns the first). ``k <= 0`` degenerates to the
|
|
122
|
+
per-row median point; ``k >= n-1`` is the whole row (the widest floor of a small S).
|
|
123
|
+
"""
|
|
124
|
+
n = srt.shape[-1]
|
|
125
|
+
if k <= 0:
|
|
126
|
+
v = srt[:, n // 2]
|
|
127
|
+
return v.copy(), v.copy()
|
|
128
|
+
if k >= n - 1:
|
|
129
|
+
return srt[:, 0].copy(), srt[:, -1].copy()
|
|
130
|
+
widths = srt[:, k:] - srt[:, : n - k]
|
|
131
|
+
i = np.argmin(widths, axis=-1)
|
|
132
|
+
lo = np.take_along_axis(srt, i[:, None], axis=-1)[:, 0]
|
|
133
|
+
hi = np.take_along_axis(srt, (i + k)[:, None], axis=-1)[:, 0]
|
|
134
|
+
return lo, hi
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _shortest_contained_in(
|
|
138
|
+
srt: NDArray[np.float32],
|
|
139
|
+
k: int,
|
|
140
|
+
parent_lo: NDArray[np.float32],
|
|
141
|
+
parent_hi: NDArray[np.float32],
|
|
142
|
+
) -> tuple[NDArray[np.float32], NDArray[np.float32]]:
|
|
143
|
+
"""Shortest ``k+1``-sample window ``[lo, hi]`` with ``parent_lo <= lo`` and
|
|
144
|
+
``hi <= parent_hi`` — the outside-in nesting step.
|
|
145
|
+
|
|
146
|
+
This is what makes the tower nested *and* robust to minority duplicates: a window
|
|
147
|
+
straddling an outlier cannot fit in a parent that already shed it. Always feasible
|
|
148
|
+
— the parent's own first ``k+1`` samples are a candidate (the parent holds at least
|
|
149
|
+
``k+1`` samples, since floors are non-decreasing in mass and built widest-first).
|
|
150
|
+
``k <= 0`` collapses to the in-parent median point.
|
|
151
|
+
"""
|
|
152
|
+
n = srt.shape[-1]
|
|
153
|
+
if k <= 0:
|
|
154
|
+
v = _mid_sample_in(srt, parent_lo, parent_hi)
|
|
155
|
+
return v, v
|
|
156
|
+
starts = srt[:, : n - k]
|
|
157
|
+
ends = srt[:, k:]
|
|
158
|
+
inside = (starts >= parent_lo[:, None]) & (ends <= parent_hi[:, None])
|
|
159
|
+
widths = np.where(inside, ends - starts, np.inf)
|
|
160
|
+
i = np.argmin(widths, axis=-1)
|
|
161
|
+
lo = np.take_along_axis(starts, i[:, None], axis=-1)[:, 0]
|
|
162
|
+
hi = np.take_along_axis(ends, i[:, None], axis=-1)[:, 0]
|
|
163
|
+
return lo, hi
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _dense_tower(srt: NDArray[np.float32], ks: NDArray[np.intp]) -> NDArray[np.float32]:
|
|
167
|
+
"""Build the full constrained-nested tower over a sorted block → ``(rows, F, 2)``.
|
|
168
|
+
|
|
169
|
+
Built **outside-in**: the widest floor (last index) first, then each narrower floor
|
|
170
|
+
contained in its wider parent. Each floor is written at its natural ascending index,
|
|
171
|
+
so the output layout, the ``[:, pin, :]`` readout, and reproducibility are unchanged
|
|
172
|
+
from the published contract — only the *fill order* runs widest→narrowest.
|
|
173
|
+
"""
|
|
174
|
+
rows = srt.shape[0]
|
|
175
|
+
f = ks.shape[0]
|
|
176
|
+
out = np.empty((rows, f, 2), dtype=np.float32)
|
|
177
|
+
plo: NDArray[np.float32] | None = None
|
|
178
|
+
phi: NDArray[np.float32] | None = None
|
|
179
|
+
for j in range(f - 1, -1, -1): # widest → narrowest
|
|
180
|
+
k = int(ks[j])
|
|
181
|
+
if plo is None or phi is None:
|
|
182
|
+
lo, hi = _shortest_seed(srt, k)
|
|
183
|
+
else:
|
|
184
|
+
lo, hi = _shortest_contained_in(srt, k, plo, phi)
|
|
185
|
+
out[:, j, 0] = lo
|
|
186
|
+
out[:, j, 1] = hi
|
|
187
|
+
plo, phi = lo, hi
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _tip_floor_index(sample_count: int) -> int:
|
|
192
|
+
"""The canonical-grid index whose mass is nearest ``config['tip_mass']``."""
|
|
193
|
+
tip_mass = float(config.get("tip_mass"))
|
|
194
|
+
return int(np.argmin(np.abs(_CANONICAL_FLOORS - tip_mass)))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def hdi_tower(
|
|
198
|
+
frame: AnyFrame,
|
|
199
|
+
masses: Sequence[float] = (0.5, 0.9, 0.99),
|
|
200
|
+
) -> NDArray[np.float32]:
|
|
201
|
+
"""Per-row constrained-nested HDIs at the requested ``masses`` → ``(N, …, M, 2)``.
|
|
202
|
+
|
|
203
|
+
Each requested mass is pinned to the nearest fixed canonical floor; the interval is
|
|
204
|
+
read out of the full canonical tower (built once per block, outside-in). Nested by
|
|
205
|
+
construction, robust to minority duplicates, and reproducible (a mass's interval is
|
|
206
|
+
independent of the other requested masses). Quiet rows (``max <= zero_cutoff``)
|
|
207
|
+
collapse to ``(0, 0)``. Aligned to ``frame.index``. Tunables come from ``config``.
|
|
208
|
+
"""
|
|
209
|
+
values = frame.values
|
|
210
|
+
lead = values.shape[:-1]
|
|
211
|
+
s = values.shape[-1]
|
|
212
|
+
ks = _ks(s)
|
|
213
|
+
pin = _pin(masses)
|
|
214
|
+
block_rows = int(config.get("row_block"))
|
|
215
|
+
flat = np.ascontiguousarray(values).reshape(-1, s)
|
|
216
|
+
|
|
217
|
+
def _block(block: NDArray[np.float32]) -> NDArray[np.float32]:
|
|
218
|
+
srt = np.sort(block, axis=-1)
|
|
219
|
+
sel = _dense_tower(srt, ks)[:, pin, :]
|
|
220
|
+
sel[_zero_mask(block)] = 0.0
|
|
221
|
+
return sel
|
|
222
|
+
|
|
223
|
+
out = block_apply(flat, block_rows, _block)
|
|
224
|
+
return np.asarray(out, dtype=np.float32).reshape(*lead, pin.shape[0], 2)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The tower-tip point estimate (ADR-019) — a `(N, …, 1)` frame.
|
|
2
|
+
|
|
3
|
+
`tower_point` is the "most likely single value" we report to a consumer: the median of
|
|
4
|
+
the draws inside the **`tip_mass` floor** of the nested tower (config-driven, default
|
|
5
|
+
0.5 — the "shorth"), with the same raw-count zero short-circuit as the tower
|
|
6
|
+
(`max(row) <= zero_cutoff` → 0).
|
|
7
|
+
|
|
8
|
+
It is a *new* estimator, deliberately distinct from the frozen `map_estimate` (ADR-018):
|
|
9
|
+
`map_estimate` is a binned histogram mode with a zero-*mass*-fraction rule and a
|
|
10
|
+
lowest-index tie-break that is directionally biased on right-skewed, zero-inflated,
|
|
11
|
+
low-sample posteriors (register C-32). The tower tip is unbinned — it reads the median
|
|
12
|
+
of a mass-aware floor — so it carries no histogram tie-break **and** is robust to
|
|
13
|
+
minority duplicated draws (register C-44; a lonely outlier cannot define a 50%-mass
|
|
14
|
+
floor). On a genuinely multi-peaked row the tip is, like any point, ambiguous; pair it
|
|
15
|
+
with `bimodality` to detect that case rather than collapse it silently.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from numpy.typing import NDArray
|
|
22
|
+
|
|
23
|
+
from views_frames_summarize import config
|
|
24
|
+
from views_frames_summarize._common import AnyFrame, block_apply, rebuild
|
|
25
|
+
from views_frames_summarize.tower import (
|
|
26
|
+
_dense_tower,
|
|
27
|
+
_ks,
|
|
28
|
+
_median_in,
|
|
29
|
+
_tip_floor_index,
|
|
30
|
+
_zero_mask,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def tower_point(frame: AnyFrame) -> AnyFrame:
|
|
35
|
+
"""Per-row tower-tip point estimate over the sample axis → a `(N, …, 1)` frame.
|
|
36
|
+
|
|
37
|
+
The median of the ``tip_mass`` floor's samples; ``0.0`` for quiet rows (every draw
|
|
38
|
+
``<= zero_cutoff``). Returns a frame of the same type as ``frame``. Tunables (the
|
|
39
|
+
``tip_mass``, the grid, the zero cutoff, the row-block) come from ``config``.
|
|
40
|
+
"""
|
|
41
|
+
values = frame.values
|
|
42
|
+
lead = values.shape[:-1]
|
|
43
|
+
s = values.shape[-1]
|
|
44
|
+
ks = _ks(s)
|
|
45
|
+
t = _tip_floor_index(s)
|
|
46
|
+
block_rows = int(config.get("row_block"))
|
|
47
|
+
flat = np.ascontiguousarray(values).reshape(-1, s)
|
|
48
|
+
|
|
49
|
+
def _block(block: NDArray[np.float32]) -> NDArray[np.float32]:
|
|
50
|
+
srt = np.sort(block, axis=-1)
|
|
51
|
+
tower = _dense_tower(srt, ks)
|
|
52
|
+
tip = _median_in(srt, tower[:, t, 0], tower[:, t, 1])
|
|
53
|
+
return np.asarray(
|
|
54
|
+
np.where(_zero_mask(block), np.float32(0.0), tip), dtype=np.float32
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
out = block_apply(flat, block_rows, _block)
|
|
58
|
+
reduced = np.asarray(out, dtype=np.float32).reshape(lead)[..., np.newaxis]
|
|
59
|
+
return rebuild(frame, reduced)
|
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
"""The constrained-nested HDI tower (ADR-019) — interval estimates over the sample axis.
|
|
2
|
-
|
|
3
|
-
`hdi_tower` returns, for each requested mass, the highest-density interval read out
|
|
4
|
-
of a **fixed canonical tower**: a dense set of shortest intervals built inside-out so
|
|
5
|
-
each floor is the shortest interval that *contains* the next-narrower one. Nesting is
|
|
6
|
-
therefore guaranteed **by construction** (no post-hoc "move to nest" patch; register
|
|
7
|
-
C-33), and because the tower is always built on the same fixed `_CANONICAL_FLOORS`
|
|
8
|
-
grid — with requested masses *pinned* to the nearest floor, never inserted — "the 50%
|
|
9
|
-
HDI" is identical regardless of which other masses a caller asks for (reproducibility).
|
|
10
|
-
|
|
11
|
-
The construction is vectorized over the sample axis and runs in row-blocks (register
|
|
12
|
-
C-22/C-25): one sort per block, never a whole-grid sorted copy. A raw-count zero
|
|
13
|
-
short-circuit (`max(row) <= 1.0` → the whole summary collapses to 0) kills the quiet
|
|
14
|
-
cells — the overwhelming majority — cheaply.
|
|
15
|
-
|
|
16
|
-
Returns numpy arrays aligned to the frame's index (the interval convention, ADR-017);
|
|
17
|
-
the caller holds the index. The private engine here (`_dense_tower`, `_tip`, `_pin`,
|
|
18
|
-
`_zero_mask`, `_CANONICAL_FLOORS`) is shared by `tower_point`, `bimodality`, and the
|
|
19
|
-
single-pass `summarize_tower` bundle.
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
from __future__ import annotations
|
|
23
|
-
|
|
24
|
-
from collections.abc import Sequence
|
|
25
|
-
from typing import Final
|
|
26
|
-
|
|
27
|
-
import numpy as np
|
|
28
|
-
from numpy.typing import NDArray
|
|
29
|
-
|
|
30
|
-
from views_frames_summarize._common import ROW_BLOCK, AnyFrame, block_apply
|
|
31
|
-
|
|
32
|
-
# The fixed canonical mass grid: a 5% body plus a fixed fine high-mass tail so a
|
|
33
|
-
# requested 0.99 pins to 0.99 (not 0.95). Built from rounded literals — NOT
|
|
34
|
-
# ``np.arange`` (whose float accumulation drifts ~1 ulp across numpy versions and
|
|
35
|
-
# would make the grid, and therefore every pinned interval, non-reproducible).
|
|
36
|
-
_CANONICAL_FLOORS: Final[NDArray[np.float64]] = np.array(
|
|
37
|
-
[round(0.05 * i, 2) for i in range(1, 19)] # 0.05 … 0.90
|
|
38
|
-
+ [0.92, 0.94, 0.95, 0.96, 0.97, 0.98, 0.99], # fine high-mass tail (incl. 0.95)
|
|
39
|
-
dtype=np.float64,
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
# Raw-count zero rule: a row whose every draw is <= this collapses to 0 (the quiet
|
|
43
|
-
# cell — no meaningful positive mass). Deliberately a *count* rule, distinct from
|
|
44
|
-
# ``map_estimate``'s zero-*mass*-fraction rule, so the two estimators stay independent.
|
|
45
|
-
_ZERO_CUTOFF: Final = 1.0
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _ks(sample_count: int) -> NDArray[np.intp]:
|
|
49
|
-
"""The per-floor window size ``k = floor(mass * S)`` for every canonical floor."""
|
|
50
|
-
return np.asarray(np.floor(_CANONICAL_FLOORS * sample_count), dtype=np.intp)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _pin(masses: Sequence[float]) -> NDArray[np.intp]:
|
|
54
|
-
"""Index of the nearest canonical floor for each requested mass.
|
|
55
|
-
|
|
56
|
-
Deterministic: ``argmin`` of the distance, breaking ties on the lowest index. A
|
|
57
|
-
mass at the midpoint between two floors (e.g. ``0.075`` between ``0.05`` and
|
|
58
|
-
``0.10``) therefore pins **down** to the lower floor — unambiguous and
|
|
59
|
-
reproducible. Fails loud (ADR-008) on a mass outside ``(0, 1)`` rather than silently
|
|
60
|
-
pinning a nonsense value to the nearest floor and returning a plausible interval.
|
|
61
|
-
"""
|
|
62
|
-
m = np.asarray(masses, dtype=np.float64)
|
|
63
|
-
if m.size == 0 or not np.all((m > 0.0) & (m < 1.0)):
|
|
64
|
-
raise ValueError(
|
|
65
|
-
f"masses must each be in the open interval (0, 1); got {masses!r}"
|
|
66
|
-
)
|
|
67
|
-
dist = np.abs(_CANONICAL_FLOORS[None, :] - m[:, None])
|
|
68
|
-
return np.asarray(np.argmin(dist, axis=1), dtype=np.intp)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def _zero_mask(block: NDArray[np.float32]) -> NDArray[np.bool_]:
|
|
72
|
-
"""Rows whose maximum draw is at/below the zero cutoff — the quiet cells."""
|
|
73
|
-
return np.asarray(block.max(axis=-1) <= _ZERO_CUTOFF, dtype=np.bool_)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def _shortest(
|
|
77
|
-
srt: NDArray[np.float32], k: int
|
|
78
|
-
) -> tuple[NDArray[np.float32], NDArray[np.float32]]:
|
|
79
|
-
"""Shortest interval holding ``k + 1`` samples, per row of a sorted block.
|
|
80
|
-
|
|
81
|
-
For ``k <= 0`` the floor cannot hold two samples (``S`` below the grid's 1/S
|
|
82
|
-
resolution); it degenerates to the per-row median point — the tower-tip seed.
|
|
83
|
-
"""
|
|
84
|
-
n = srt.shape[-1]
|
|
85
|
-
if k <= 0:
|
|
86
|
-
v = srt[:, n // 2]
|
|
87
|
-
return v.copy(), v.copy()
|
|
88
|
-
widths = srt[:, k:] - srt[:, : n - k]
|
|
89
|
-
i = np.argmin(widths, axis=-1)
|
|
90
|
-
lo = np.take_along_axis(srt, i[:, None], axis=-1)[:, 0]
|
|
91
|
-
hi = np.take_along_axis(srt, (i + k)[:, None], axis=-1)[:, 0]
|
|
92
|
-
return lo, hi
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def _shortest_containing(
|
|
96
|
-
srt: NDArray[np.float32],
|
|
97
|
-
k: int,
|
|
98
|
-
plo: NDArray[np.float32],
|
|
99
|
-
phi: NDArray[np.float32],
|
|
100
|
-
) -> tuple[NDArray[np.float32], NDArray[np.float32]]:
|
|
101
|
-
"""Shortest ``k+1``-sample interval that contains the inner floor ``[plo, phi]``.
|
|
102
|
-
|
|
103
|
-
This is what makes the tower nested by construction. ``k <= 0`` inherits the inner
|
|
104
|
-
floor; ``k >= n-1`` is the whole row.
|
|
105
|
-
"""
|
|
106
|
-
n = srt.shape[-1]
|
|
107
|
-
if k <= 0:
|
|
108
|
-
return plo.copy(), phi.copy()
|
|
109
|
-
if k >= n - 1:
|
|
110
|
-
return srt[:, 0].copy(), srt[:, -1].copy()
|
|
111
|
-
starts = srt[:, : n - k]
|
|
112
|
-
ends = srt[:, k:]
|
|
113
|
-
ok = (starts <= plo[:, None]) & (ends >= phi[:, None])
|
|
114
|
-
widths = np.where(ok, ends - starts, np.inf)
|
|
115
|
-
i = np.argmin(widths, axis=-1)
|
|
116
|
-
lo = np.take_along_axis(starts, i[:, None], axis=-1)[:, 0]
|
|
117
|
-
hi = np.take_along_axis(ends, i[:, None], axis=-1)[:, 0]
|
|
118
|
-
# Defensive: the construction guarantees an inner floor is itself a sample window,
|
|
119
|
-
# so a wider containing window always exists — but if that invariant is ever
|
|
120
|
-
# broken, expand minimally rather than emit a non-nested floor.
|
|
121
|
-
bad = ~np.isfinite(widths.min(axis=-1))
|
|
122
|
-
if bad.any(): # pragma: no cover - unreachable while inner ⊂ outer holds
|
|
123
|
-
lo = np.where(bad, np.minimum(srt[:, 0], plo), lo)
|
|
124
|
-
hi = np.where(bad, np.maximum(srt[:, -1], phi), hi)
|
|
125
|
-
return lo, hi
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def _dense_tower(srt: NDArray[np.float32], ks: NDArray[np.intp]) -> NDArray[np.float32]:
|
|
129
|
-
"""Build the full constrained-nested tower over a sorted block → ``(rows, F, 2)``.
|
|
130
|
-
|
|
131
|
-
F is the number of canonical floors; each is nested in the next-wider one.
|
|
132
|
-
"""
|
|
133
|
-
rows = srt.shape[0]
|
|
134
|
-
out = np.empty((rows, ks.shape[0], 2), dtype=np.float32)
|
|
135
|
-
plo: NDArray[np.float32] | None = None
|
|
136
|
-
phi: NDArray[np.float32] | None = None
|
|
137
|
-
for j, k in enumerate(ks):
|
|
138
|
-
if plo is None or phi is None:
|
|
139
|
-
lo, hi = _shortest(srt, int(k))
|
|
140
|
-
else:
|
|
141
|
-
lo, hi = _shortest_containing(srt, int(k), plo, phi)
|
|
142
|
-
out[:, j, 0] = lo
|
|
143
|
-
out[:, j, 1] = hi
|
|
144
|
-
plo, phi = lo, hi
|
|
145
|
-
return out
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def _tip(srt: NDArray[np.float32], k0: int) -> NDArray[np.float32]:
|
|
149
|
-
"""The tower tip: the median of the narrowest floor's ``k0 + 1`` samples, per row.
|
|
150
|
-
|
|
151
|
-
For ``k0 <= 0`` the floor is the median point itself.
|
|
152
|
-
"""
|
|
153
|
-
n = srt.shape[-1]
|
|
154
|
-
if k0 <= 0:
|
|
155
|
-
return np.array(srt[:, n // 2], dtype=np.float32)
|
|
156
|
-
widths = srt[:, k0:] - srt[:, : n - k0]
|
|
157
|
-
i = np.argmin(widths, axis=-1)
|
|
158
|
-
lo_mid = i + (k0 // 2)
|
|
159
|
-
hi_mid = i + ((k0 + 1) // 2)
|
|
160
|
-
a = np.take_along_axis(srt, lo_mid[:, None], axis=-1)[:, 0]
|
|
161
|
-
b = np.take_along_axis(srt, hi_mid[:, None], axis=-1)[:, 0]
|
|
162
|
-
return np.asarray((a + b) * 0.5, dtype=np.float32)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def hdi_tower(
|
|
166
|
-
frame: AnyFrame,
|
|
167
|
-
masses: Sequence[float] = (0.5, 0.9, 0.99),
|
|
168
|
-
*,
|
|
169
|
-
block_rows: int = ROW_BLOCK,
|
|
170
|
-
) -> NDArray[np.float32]:
|
|
171
|
-
"""Per-row constrained-nested HDIs at the requested ``masses`` → ``(N, …, M, 2)``.
|
|
172
|
-
|
|
173
|
-
Each requested mass is pinned to the nearest fixed canonical floor; the interval is
|
|
174
|
-
read out of the full canonical tower (built once per block). Nested by construction
|
|
175
|
-
and reproducible (a mass's interval is independent of the other requested masses).
|
|
176
|
-
Quiet rows (``max <= 1``) collapse to ``(0, 0)``. Aligned to ``frame.index``.
|
|
177
|
-
"""
|
|
178
|
-
values = frame.values
|
|
179
|
-
lead = values.shape[:-1]
|
|
180
|
-
s = values.shape[-1]
|
|
181
|
-
ks = _ks(s)
|
|
182
|
-
pin = _pin(masses)
|
|
183
|
-
flat = np.ascontiguousarray(values).reshape(-1, s)
|
|
184
|
-
|
|
185
|
-
def _block(block: NDArray[np.float32]) -> NDArray[np.float32]:
|
|
186
|
-
srt = np.sort(block, axis=-1)
|
|
187
|
-
sel = _dense_tower(srt, ks)[:, pin, :]
|
|
188
|
-
sel[_zero_mask(block)] = 0.0
|
|
189
|
-
return sel
|
|
190
|
-
|
|
191
|
-
out = block_apply(flat, block_rows, _block)
|
|
192
|
-
return np.asarray(out, dtype=np.float32).reshape(*lead, pin.shape[0], 2)
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
"""The tower-tip point estimate (ADR-019) — a `(N, …, 1)` frame.
|
|
2
|
-
|
|
3
|
-
`tower_point` is the "most likely single value" we report to a consumer: the median of
|
|
4
|
-
the draws inside the **narrowest canonical floor** of the constrained-nested tower (the
|
|
5
|
-
tip), with the same raw-count zero short-circuit as the tower (`max(row) <= 1` → 0).
|
|
6
|
-
|
|
7
|
-
It is a *new* estimator, deliberately distinct from the frozen `map_estimate` (ADR-018):
|
|
8
|
-
`map_estimate` is a binned histogram mode with a zero-*mass*-fraction rule and a
|
|
9
|
-
lowest-index tie-break that is directionally biased on right-skewed, zero-inflated,
|
|
10
|
-
low-sample posteriors (register C-32). The tower tip is unbinned — it reads the median
|
|
11
|
-
of an actual shortest-interval window — so it carries no histogram tie-break. On a
|
|
12
|
-
genuinely multi-peaked row the tip is, like any single point, ambiguous; pair it with
|
|
13
|
-
`bimodality` to detect that case rather than collapse it silently.
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
from __future__ import annotations
|
|
17
|
-
|
|
18
|
-
import numpy as np
|
|
19
|
-
from numpy.typing import NDArray
|
|
20
|
-
|
|
21
|
-
from views_frames_summarize._common import ROW_BLOCK, AnyFrame, block_apply, rebuild
|
|
22
|
-
from views_frames_summarize.tower import _CANONICAL_FLOORS, _tip, _zero_mask
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def tower_point(frame: AnyFrame, *, block_rows: int = ROW_BLOCK) -> AnyFrame:
|
|
26
|
-
"""Per-row tower-tip point estimate over the sample axis → a `(N, …, 1)` frame.
|
|
27
|
-
|
|
28
|
-
The median of the narrowest canonical floor's samples; ``0.0`` for quiet rows
|
|
29
|
-
(every draw ``<= 1``). Returns a frame of the same type as ``frame``.
|
|
30
|
-
"""
|
|
31
|
-
values = frame.values
|
|
32
|
-
lead = values.shape[:-1]
|
|
33
|
-
s = values.shape[-1]
|
|
34
|
-
k0 = int(np.floor(_CANONICAL_FLOORS[0] * s))
|
|
35
|
-
flat = np.ascontiguousarray(values).reshape(-1, s)
|
|
36
|
-
|
|
37
|
-
def _block(block: NDArray[np.float32]) -> NDArray[np.float32]:
|
|
38
|
-
srt = np.sort(block, axis=-1)
|
|
39
|
-
tip = _tip(srt, k0)
|
|
40
|
-
return np.asarray(
|
|
41
|
-
np.where(_zero_mask(block), np.float32(0.0), tip), dtype=np.float32
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
out = block_apply(flat, block_rows, _block)
|
|
45
|
-
reduced = np.asarray(out, dtype=np.float32).reshape(lead)[..., np.newaxis]
|
|
46
|
-
return rebuild(frame, reduced)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|